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
507const DEPTH_HISTORY_RUNS: usize = 2880;
510
511#[derive(Default)]
514struct DepthHistory(std::sync::Mutex<Vec<(i64, f64, f64)>>);
515
516impl DepthHistory {
517 fn record(&self, summary: &mut MarketSummary) {
520 self.record_at(summary, chrono::Utc::now().timestamp_millis());
521 }
522
523 fn record_at(&self, summary: &mut MarketSummary, at_ms: i64) {
525 let mut points = self.0.lock().unwrap_or_else(|e| e.into_inner());
526 points.push((at_ms, summary.bid_depth, summary.ask_depth));
527 let excess = points.len().saturating_sub(DEPTH_HISTORY_RUNS);
528 points.drain(..excess);
529 let bid: Vec<(i64, f64)> = points.iter().map(|p| (p.0, p.1)).collect();
530 let ask: Vec<(i64, f64)> = points.iter().map(|p| (p.0, p.2)).collect();
531 summary.check_depth_cycles(&bid, &ask);
532 }
533}
534
535async fn summary_run(
536 args: &SummaryArgs,
537 factory: &dyn ChainClientFactory,
538 thresholds: &HealthThresholds,
539 tokens: &[TokenAddress],
540 run_num: Option<u64>,
541 history: Option<&DepthHistory>,
542 out: &Output,
543) -> Result<(MarketSummary, String)> {
544 let (book, volume_24h, trades) = fetch_book_and_volume(args, thresholds, factory, out).await?;
545 let mut summary = MarketSummary::from_order_book(&book, thresholds, volume_24h);
546 summary.check_bot_patterns(trades.as_deref().map_err(String::as_str));
547 if let Some(history) = history {
548 history.record(&mut summary);
549 }
550 let report = match args.format {
551 SummaryFormat::Text => summary.format_text_with_tokens(Some(&args.venue), tokens),
552 SummaryFormat::Json => {
553 let mut json = summary.to_json();
554 if let Some(obj) = json.as_object_mut() {
555 obj.insert("run".to_string(), serde_json::json!(run_num));
556 obj.insert("venue".to_string(), serde_json::json!(args.venue));
557 if !tokens.is_empty() {
558 obj.insert("tokens".to_string(), serde_json::json!(tokens));
559 }
560 }
561 format!("{}\n", serde_json::to_string_pretty(&json)?)
562 }
563 };
564 Ok((summary, report))
565}
566
567fn write_report(args: &SummaryArgs, summary: &MarketSummary, out: &Output) -> Result<()> {
569 if let Some(ref report_path) = args.report {
570 let md = market_summary_to_markdown(summary, &args.venue, &args.pair);
571 std::fs::write(report_path, md)?;
572 out.note_saved("Report", report_path.display());
573 }
574 Ok(())
575}
576
577fn summary_schedule(args: &SummaryArgs) -> Result<Option<Schedule>> {
579 if args.every.is_none() && args.duration.is_none() {
580 return Ok(None);
581 }
582 let secs = |flag: &Option<String>, default| {
583 flag.as_deref()
584 .map(parse_duration)
585 .transpose()
586 .map(|v| v.unwrap_or(default))
587 };
588 let every = Duration::from_secs(secs(&args.every, DEFAULT_EVERY_SECS)?);
589 if args.duration.as_deref().is_some_and(is_until_cancelled) {
590 return Schedule::until_cancelled(every).map(Some);
591 }
592 let duration = secs(&args.duration, DEFAULT_DURATION_SECS)?;
593 Schedule::new(every, Duration::from_secs(duration)).map(Some)
594}
595
596fn is_until_cancelled(s: &str) -> bool {
598 scope::watch::time::is_until_cancelled(s)
599}
600
601async fn run_summary(
602 args: SummaryArgs,
603 config: &Config,
604 factory: &dyn ChainClientFactory,
605 out: &Output,
606) -> Result<()> {
607 let thresholds = args.health.resolve(config);
608 let schedule = summary_schedule(&args)?;
610 let mut args = args;
611 if let Some(contract) = resolve_market_token(&args, factory, out).await? {
613 args.token_contract = Some(contract);
614 }
615 let tokens = token_addresses(&args, factory.http(), out).await;
617
618 let Some(schedule) = schedule else {
619 let (summary, report) =
620 summary_run(&args, factory, &thresholds, &tokens, None, None, out).await?;
621 out!(out, "{report}")?;
622 out.record_view(SummaryView {
623 summary: summary.clone(),
624 venue: args.venue.clone(),
625 tokens: tokens.clone(),
626 });
627 return write_report(&args, &summary, out);
628 };
629
630 note!(
631 out,
632 "Running market summary every {}s {} (Ctrl+C to stop)\n",
633 schedule.every.as_secs(),
634 match schedule.duration {
635 Some(d) => format!("for {}s", d.as_secs()),
636 None => "until cancelled".to_string(),
637 }
638 );
639 let metrics = series::summary_metrics(&thresholds);
640 let history = DepthHistory::default();
641 let last = repeat::run(&schedule, &metrics, args.csv.as_deref(), out, |n| {
642 let (args, thresholds, metrics, tokens, history) =
643 (&args, &thresholds, &metrics, &tokens, &history);
644 async move {
645 let (summary, report) = summary_run(
646 args,
647 factory,
648 thresholds,
649 tokens,
650 Some(n),
651 Some(history),
652 out,
653 )
654 .await?;
655 Ok(RunOutput {
656 sample: series::sample(&summary, metrics),
657 snapshot: report,
658 view: Some(SummaryView {
659 summary: summary.clone(),
660 venue: args.venue.clone(),
661 tokens: tokens.to_vec(),
662 }),
663 value: summary,
664 })
665 }
666 })
667 .await?;
668
669 if let Some(summary) = last {
670 write_report(&args, &summary, out)?;
671 }
672 if let Some(ref csv_path) = args.csv {
673 out.note_saved("Time-series CSV", csv_path.display());
674 }
675 Ok(())
676}
677
678async fn run_ohlc(args: OhlcArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
684 let json = matches!(args.format, OhlcFormat::Json);
685 check_venue_token(
686 &args.venue,
687 &args.pair,
688 None,
689 args.token_contract.clone(),
690 json,
691 http.clone(),
692 out,
693 )
694 .await?;
695 let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
696 run_ohlc_with(&client, args, out).await
697}
698
699async fn run_ohlc_with(client: &ExchangeClient, args: OhlcArgs, out: &Output) -> Result<()> {
701 let pair = client.checked_symbol(&Pair::parse(&args.pair))?;
702 let width = t::terminal_width();
703 let limit = args.limit.unwrap_or(candles::columns_for(width, 10) as u32);
706
707 let mut candles = client.fetch_ohlc(&pair, &args.interval, limit).await?;
708 candles.sort_by_key(|c| c.open_time);
711
712 match args.format {
713 OhlcFormat::Json => {
714 let json_str = serde_json::to_string_pretty(&candles)
715 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
716 outln!(out, "{json_str}")?;
717 }
718 OhlcFormat::Text => {
719 outln!(
720 out,
721 "{}",
722 t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
723 )?;
724 write_candle_chart(&candles, &args.interval, width, out)?;
725 if args.table {
726 write_candle_table(&candles, out)?;
727 }
728 outln!(out, "{}", t::section_footer())?;
729 }
730 }
731 Ok(())
732}
733
734fn write_candle_chart(
737 all: &[scope::market::Candle],
738 interval: &str,
739 width: usize,
740 out: &Output,
741) -> Result<()> {
742 let Some(sum) = candles::summary(all) else {
743 outln!(out, "{}", t::info_row("No candles returned"))?;
744 return Ok(());
745 };
746 let columns = candles::columns_for(width, candles::axis_cols(sum.low, sum.high));
747 let shown = &all[all.len().saturating_sub(columns)..];
748 let daily = interval.ends_with(['d', 'w', 'M']);
750 let time = |ms: u64| {
751 chrono::DateTime::from_timestamp_millis(ms as i64)
752 .map(|d| {
753 let local = d.with_timezone(&chrono::Local);
754 if daily {
755 local.format("%Y-%m-%d").to_string()
756 } else {
757 local.format("%m-%d %H:%M").to_string()
758 }
759 })
760 .unwrap_or_default()
761 };
762 for line in candles::chart(shown, candles::CHART_ROWS, t::painter(), time) {
763 outln!(out, "{}", t::chart_row(&line))?;
764 }
765 let count = if shown.len() < all.len() {
766 format!(
767 "{interval} · {} candles (the chart shows the last {})",
768 all.len(),
769 shown.len()
770 )
771 } else {
772 format!("{interval} · {} candles", all.len())
773 };
774 outln!(out, "{}", t::kv_row("Interval", &count))?;
775 let price = scope::display::format::format_price;
776 outln!(out, "{}", t::kv_row("Open", &price(sum.open)))?;
777 outln!(out, "{}", t::kv_row("High", &price(sum.high)))?;
778 outln!(out, "{}", t::kv_row("Low", &price(sum.low)))?;
779 let close = match sum.change_pct {
780 Some(pct) => format!("{} ({pct:+.2}%)", price(sum.close)),
781 None => price(sum.close),
782 };
783 outln!(out, "{}", t::kv_row("Close", &close))?;
784 outln!(
785 out,
786 "{}",
787 t::kv_row("Volume", &scope::display::format_large_number(sum.volume))
788 )?;
789 Ok(())
790}
791
792fn write_candle_table(candles: &[scope::market::Candle], out: &Output) -> Result<()> {
794 let cols = [
795 t::Col {
796 label: "Open Time",
797 width: 19,
798 align: '>',
799 },
800 t::Col {
801 label: "Open",
802 width: 12,
803 align: '>',
804 },
805 t::Col {
806 label: "High",
807 width: 12,
808 align: '>',
809 },
810 t::Col {
811 label: "Low",
812 width: 12,
813 align: '>',
814 },
815 t::Col {
816 label: "Close",
817 width: 12,
818 align: '>',
819 },
820 t::Col {
821 label: "Volume",
822 width: 14,
823 align: '>',
824 },
825 ];
826 outln!(out, "{}", t::table_header(&cols))?;
827 for c in candles {
828 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
829 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
830 .unwrap_or_else(|| format!("{}", c.open_time));
831 let open_str = format!("{:.6}", c.open);
832 let high_str = format!("{:.6}", c.high);
833 let low_str = format!("{:.6}", c.low);
834 let close_str = format!("{:.6}", c.close);
835 let volume_str = format!("{:.2}", c.volume);
836 let values = [
837 dt.as_str(),
838 open_str.as_str(),
839 high_str.as_str(),
840 low_str.as_str(),
841 close_str.as_str(),
842 volume_str.as_str(),
843 ];
844 outln!(out, "{}", t::table_row(&cols, &values))?;
845 }
846 outln!(
847 out,
848 "{}",
849 t::info_row(&format!("{} candles returned", candles.len()))
850 )?;
851 Ok(())
852}
853
854async fn run_trades(args: TradesArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
860 let json = matches!(args.format, OhlcFormat::Json);
861 check_venue_token(
862 &args.venue,
863 &args.pair,
864 None,
865 args.token_contract.clone(),
866 json,
867 http.clone(),
868 out,
869 )
870 .await?;
871 let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
872 run_trades_with(&client, args, out).await
873}
874
875async fn run_trades_with(client: &ExchangeClient, args: TradesArgs, out: &Output) -> Result<()> {
877 let pair = client.checked_symbol(&Pair::parse(&args.pair))?;
878
879 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
880
881 match args.format {
882 OhlcFormat::Json => {
883 let json_str = serde_json::to_string_pretty(&trades)
884 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
885 outln!(out, "{json_str}")?;
886 }
887 OhlcFormat::Text => {
888 outln!(
889 out,
890 "{}",
891 t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
892 )?;
893
894 let cols = [
895 t::Col {
896 label: "Time",
897 width: 10,
898 align: '>',
899 },
900 t::Col {
901 label: "Side",
902 width: 5,
903 align: '>',
904 },
905 t::Col {
906 label: "Price",
907 width: 12,
908 align: '>',
909 },
910 t::Col {
911 label: "Qty",
912 width: 12,
913 align: '>',
914 },
915 ];
916 outln!(out, "{}", t::table_header(&cols))?;
917
918 for t in &trades {
919 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
920 .map(|d| d.format("%H:%M:%S").to_string())
921 .unwrap_or_else(|| "?".to_string());
922 let side_str = match t.side {
923 scope::market::TradeSide::Buy => "BUY",
924 scope::market::TradeSide::Sell => "SELL",
925 };
926 let price_str = format!("{:.6}", t.price);
927 let qty_str = format!("{:.2}", t.quantity);
928 let values = [
929 time.as_str(),
930 side_str,
931 price_str.as_str(),
932 qty_str.as_str(),
933 ];
934 outln!(out, "{}", t::table_row(&cols, &values))?;
935 }
936
937 outln!(
938 out,
939 "{}",
940 t::info_row(&format!("{} trades returned", trades.len()))
941 )?;
942 outln!(out, "{}", t::section_footer())?;
943 }
944 }
945 Ok(())
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951 use scope::chains::DefaultClientFactory;
952 use scope::market::DexVenue;
953
954 fn quiet() -> crate::cli::output::Output {
955 crate::cli::output::Output::capture().0
956 }
957
958 #[allow(dead_code)]
962 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
963 let venue_id = format!("test_mock_{}", std::process::id());
964 let yaml = format!(
965 r#"
966id: {venue_id}
967name: Test Mock Venue
968base_url: {server_url}
969timeout_secs: 5
970symbol:
971 template: "{{base}}_{{quote}}"
972 default_quote: USDT
973capabilities:
974 order_book:
975 path: /api/v1/depth
976 params:
977 symbol: "{{pair}}"
978 response:
979 asks_key: asks
980 bids_key: bids
981 level_format: positional
982 ticker:
983 path: /api/v1/ticker
984 params:
985 symbol: "{{pair}}"
986 response:
987 last_price: last
988 volume_24h: vol
989"#
990 );
991 let dir = tempfile::tempdir().unwrap();
992 let file_path = dir.path().join(format!("{}.yaml", venue_id));
993 std::fs::write(&file_path, yaml).unwrap();
994 (venue_id, dir)
997 }
998
999 #[tokio::test]
1000 async fn test_run_summary_with_mock_orderbook() {
1001 let args = SummaryArgs {
1005 pair: "USDC".to_string(),
1006 venue: "eth".to_string(),
1007 token_contract: None,
1008 chain: None,
1009 health: HealthArgs {
1010 peg_target: Some(1.0),
1011 peg_range: Some(0.01),
1012 min_levels: Some(1),
1013 min_depth: Some(50.0),
1014 min_bid_ask_ratio: Some(0.1),
1015 max_bid_ask_ratio: Some(10.0),
1016 ..Default::default()
1017 },
1018 format: SummaryFormat::Text,
1019 every: None,
1020 duration: None,
1021 report: None,
1022 csv: None,
1023 };
1024
1025 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1026 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1027 let factory = DefaultClientFactory {
1028 chains_config: Default::default(),
1029 http,
1030 };
1031 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1033 }
1035
1036 #[tokio::test]
1037 async fn test_run_summary_json_format() {
1038 let args = SummaryArgs {
1039 pair: "USDC".to_string(),
1040 venue: "eth".to_string(),
1041 token_contract: None,
1042 chain: None,
1043 health: HealthArgs {
1044 peg_target: Some(1.0),
1045 peg_range: Some(0.01),
1046 min_levels: Some(1),
1047 min_depth: Some(50.0),
1048 min_bid_ask_ratio: Some(0.1),
1049 max_bid_ask_ratio: Some(10.0),
1050 ..Default::default()
1051 },
1052 format: SummaryFormat::Json,
1053 every: None,
1054 duration: None,
1055 report: None,
1056 csv: None,
1057 };
1058
1059 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1060 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1061 let factory = DefaultClientFactory {
1062 chains_config: Default::default(),
1063 http,
1064 };
1065 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1066 }
1067
1068 #[test]
1069 fn test_duration_forever_has_no_end_and_the_default_stays_one_hour() {
1070 let args = |duration: Option<&str>| SummaryArgs {
1073 pair: "USDC".to_string(),
1074 venue: "binance".to_string(),
1075 token_contract: None,
1076 chain: None,
1077 health: HealthArgs::default(),
1078 format: SummaryFormat::Text,
1079 every: Some("30s".to_string()),
1080 duration: duration.map(str::to_string),
1081 report: None,
1082 csv: None,
1083 };
1084 for word in ["forever", "inf", "FOREVER"] {
1085 let s = summary_schedule(&args(Some(word))).unwrap().unwrap();
1086 assert_eq!(s.duration, None, "{word}");
1087 }
1088 let s = summary_schedule(&args(None)).unwrap().unwrap();
1089 assert_eq!(s.duration, Some(Duration::from_secs(DEFAULT_DURATION_SECS)));
1090 let s = summary_schedule(&args(Some("10m"))).unwrap().unwrap();
1091 assert_eq!(s.duration, Some(Duration::from_secs(600)));
1092 }
1093
1094 #[test]
1095 fn test_parse_duration_seconds() {
1096 assert_eq!(parse_duration("30s").unwrap(), 30);
1097 assert!(
1098 parse_duration("forever").is_err(),
1099 "not a span; --duration only"
1100 );
1101 assert_eq!(parse_duration("1").unwrap(), 1);
1102 assert_eq!(parse_duration("60sec").unwrap(), 60);
1103 }
1104
1105 #[test]
1106 fn test_parse_duration_minutes() {
1107 assert_eq!(parse_duration("5m").unwrap(), 300);
1108 assert_eq!(parse_duration("1min").unwrap(), 60);
1109 assert_eq!(parse_duration("2.5m").unwrap(), 150);
1110 }
1111
1112 #[test]
1113 fn test_parse_duration_hours() {
1114 assert_eq!(parse_duration("1h").unwrap(), 3600);
1115 assert_eq!(parse_duration("24h").unwrap(), 86400);
1116 }
1117
1118 #[test]
1119 fn test_parse_duration_invalid() {
1120 assert!(parse_duration("").is_err());
1121 assert!(parse_duration("abc").is_err());
1122 assert!(parse_duration("30x").is_err());
1123 }
1124
1125 #[test]
1126 fn test_parse_duration_unknown_unit_error_message() {
1127 let result = parse_duration("30z");
1128 assert!(result.is_err());
1129 let err = result.unwrap_err();
1130 assert!(err.to_string().contains("Unknown duration unit"));
1131 assert!(err.to_string().contains("z"));
1132 }
1133
1134 #[test]
1135 fn test_parse_duration_invalid_number_error() {
1136 let result = parse_duration("abc30s");
1137 assert!(result.is_err());
1138 }
1139
1140 #[test]
1141 fn test_parse_duration_non_positive() {
1142 assert!(parse_duration("0").is_err());
1143 assert!(parse_duration("-5s").is_err());
1144 }
1145
1146 #[test]
1151 fn test_base_symbol_from_pair_underscore() {
1152 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1153 assert_eq!(Pair::parse("USDC_USDT").base, "USDC");
1154 }
1155
1156 #[test]
1157 fn test_base_symbol_from_pair_lowercase_underscore() {
1158 assert_eq!(Pair::parse("dai_usdt").base, "dai");
1159 }
1160
1161 #[test]
1162 fn test_base_symbol_from_pair_slash() {
1163 assert_eq!(Pair::parse("USDC/USDT").base, "USDC");
1164 }
1165
1166 #[test]
1167 fn test_base_symbol_from_pair_concat() {
1168 assert_eq!(Pair::parse("USDCUSDT").base, "USDC");
1169 assert_eq!(Pair::parse("DAIUSDT").base, "DAI");
1170 }
1171
1172 #[test]
1173 fn test_base_symbol_from_pair_plain() {
1174 assert_eq!(Pair::parse("USDC").base, "USDC");
1175 assert_eq!(Pair::parse("ETH").base, "ETH");
1176 }
1177
1178 #[test]
1179 fn test_base_symbol_from_pair_whitespace() {
1180 assert_eq!(Pair::parse(" DAI_USDT ").base, "DAI");
1181 }
1182
1183 #[test]
1184 fn test_normalize_pair_input_key_value() {
1185 assert_eq!(Pair::parse("pair=USDT_PUSD").to_string(), "USDT/PUSD");
1186 assert_eq!(
1187 Pair::parse("pair_symbol=USDT_PUSD").to_string(),
1188 "USDT/PUSD"
1189 );
1190 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1191 }
1192
1193 #[test]
1194 fn test_parse_pair_components_delimited() {
1195 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1196 assert_eq!(Pair::parse("ETH/USDC").to_string(), "ETH/USDC");
1197 assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
1198 }
1199
1200 #[test]
1201 fn test_parse_pair_components_concat_common_quote() {
1202 assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
1203 assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
1204 assert_eq!(Pair::parse("ARB").quote, None);
1205 }
1206
1207 #[test]
1212 fn test_market_summary_to_markdown_basic() {
1213 use scope::market::{HealthCheck, MarketSummary};
1214 let summary = MarketSummary {
1215 pair: "USDCUSDT".to_string(),
1216 source: scope::market::BookSource::Exchange,
1217 peg_target: 1.0,
1218 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1219 best_bid: Some(0.9999),
1220 best_ask: Some(1.0001),
1221 mid_price: Some(1.0000),
1222 spread: Some(0.0002),
1223 spread_pct: None,
1224 top_depth: Default::default(),
1225 volume_24h: Some(1_000_000.0),
1226 bid_depth: 50_000.0,
1227 ask_depth: 50_000.0,
1228 bid_outliers: 0,
1229 ask_outliers: 0,
1230 healthy: true,
1231 bot_patterns: None,
1232 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1233 execution_10k_buy: None,
1234 execution_10k_sell: None,
1235 asks: vec![],
1236 bids: vec![],
1237 };
1238 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1239 assert!(md.contains("Market Health Report"));
1240 assert!(md.contains("USDCUSDT"));
1241 assert!(md.contains("Binance"));
1242 assert!(md.contains("Peg Target"));
1243 assert!(md.contains("1.0000"));
1244 assert!(md.contains("Healthy"));
1245 }
1246
1247 #[test]
1248 fn test_market_summary_to_markdown_no_prices() {
1249 use scope::market::{HealthCheck, MarketSummary};
1250 let summary = MarketSummary {
1251 pair: "TESTUSDT".to_string(),
1252 source: scope::market::BookSource::Exchange,
1253 peg_target: 1.0,
1254 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1255 best_bid: None,
1256 best_ask: None,
1257 mid_price: None,
1258 spread: None,
1259 spread_pct: None,
1260 top_depth: Default::default(),
1261 volume_24h: None,
1262 bid_depth: 0.0,
1263 ask_depth: 0.0,
1264 bid_outliers: 0,
1265 ask_outliers: 0,
1266 healthy: false,
1267 bot_patterns: None,
1268 checks: vec![HealthCheck::Fail("No data".to_string())],
1269 execution_10k_buy: None,
1270 execution_10k_sell: None,
1271 asks: vec![],
1272 bids: vec![],
1273 };
1274 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1275 assert!(md.contains("Market Health Report"));
1276 assert!(md.contains("-")); }
1278
1279 #[test]
1284 fn test_parse_duration_days() {
1285 assert_eq!(parse_duration("1d").unwrap(), 86400);
1286 assert_eq!(parse_duration("7d").unwrap(), 604800);
1287 assert_eq!(parse_duration("1day").unwrap(), 86400);
1288 assert_eq!(parse_duration("2days").unwrap(), 172800);
1289 }
1290
1291 #[test]
1292 fn test_parse_duration_long_names() {
1293 assert_eq!(parse_duration("30seconds").unwrap(), 30);
1294 assert_eq!(parse_duration("5minutes").unwrap(), 300);
1295 assert_eq!(parse_duration("2hours").unwrap(), 7200);
1296 }
1297
1298 #[test]
1299 fn test_parse_duration_fractional() {
1300 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1301 assert_eq!(parse_duration("1.5m").unwrap(), 90);
1302 }
1303
1304 #[test]
1309 fn test_summary_format_default() {
1310 let fmt = SummaryFormat::default();
1311 assert!(matches!(fmt, SummaryFormat::Text));
1312 }
1313
1314 #[test]
1315 fn test_summary_format_debug() {
1316 let text = format!("{:?}", SummaryFormat::Text);
1317 assert_eq!(text, "Text");
1318 let json = format!("{:?}", SummaryFormat::Json);
1319 assert_eq!(json, "Json");
1320 }
1321
1322 #[test]
1327 fn test_ohlc_args_deserialization() {
1328 use crate::cli::{Cli, Commands};
1329 use clap::Parser;
1330 let cli = Cli::try_parse_from([
1331 "scope",
1332 "market",
1333 "ohlc",
1334 "USDC",
1335 "--venue",
1336 "binance",
1337 "--interval",
1338 "1h",
1339 "--limit",
1340 "50",
1341 ])
1342 .unwrap();
1343 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1344 assert_eq!(args.pair, "USDC");
1345 assert_eq!(args.venue, "binance");
1346 assert_eq!(args.interval, "1h");
1347 assert_eq!(args.limit, Some(50));
1348 } else {
1349 panic!("Expected Market Ohlc command");
1350 }
1351 }
1352
1353 #[test]
1354 fn test_trades_args_deserialization() {
1355 use crate::cli::{Cli, Commands};
1356 use clap::Parser;
1357 let cli = Cli::try_parse_from([
1358 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1359 ])
1360 .unwrap();
1361 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1362 assert_eq!(args.pair, "BTC");
1363 assert_eq!(args.venue, "mexc");
1364 assert_eq!(args.limit, 100);
1365 } else {
1366 panic!("Expected Market Trades command");
1367 }
1368 }
1369
1370 #[test]
1371 fn test_base_symbol_from_pair_various_inputs() {
1372 assert_eq!(Pair::parse("USDC").base, "USDC");
1374 assert_eq!(Pair::parse("BTCUSDT").base, "BTC");
1375 assert_eq!(Pair::parse("ETH/USDT").base, "ETH");
1376 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1377 assert_eq!(Pair::parse("X").base, "X"); assert_eq!(Pair::parse("").base, "");
1379 }
1380
1381 #[test]
1382 fn test_summary_args_debug() {
1383 let args = SummaryArgs {
1384 pair: "USDC".to_string(),
1385 venue: "binance".to_string(),
1386 token_contract: None,
1387 chain: None,
1388 health: HealthArgs {
1389 peg_target: Some(1.0),
1390 peg_range: Some(0.001),
1391 min_levels: Some(6),
1392 min_depth: Some(3000.0),
1393 min_bid_ask_ratio: Some(0.2),
1394 max_bid_ask_ratio: Some(5.0),
1395 ..Default::default()
1396 },
1397 format: SummaryFormat::Text,
1398 every: None,
1399 duration: None,
1400 report: None,
1401 csv: None,
1402 };
1403 let debug = format!("{:?}", args);
1404 assert!(debug.contains("SummaryArgs"));
1405 assert!(debug.contains("USDC"));
1406 }
1407
1408 #[test]
1409 fn test_default_constants() {
1410 assert_eq!(DEFAULT_EVERY_SECS, 60);
1411 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1412 }
1413
1414 #[test]
1415 fn test_markdown_leaves_out_the_peg_deviation_when_there_is_none() {
1416 use scope::market::{BookSource, OrderBook, OrderBookLevel};
1418 let level = |price| OrderBookLevel {
1419 price,
1420 quantity: 100.0,
1421 };
1422 let book = OrderBook {
1423 pair: "USDC/USDT".to_string(),
1424 source: BookSource::Exchange,
1425 bids: vec![level(0.9999)],
1426 asks: vec![level(1.0001)],
1427 };
1428 let thresholds = HealthThresholds {
1429 peg_target: 0.0,
1430 ..HealthThresholds::default()
1431 };
1432 let summary = MarketSummary::from_order_book(&book, &thresholds, None);
1433 let md = market_summary_to_markdown(&summary, "binance", "USDC");
1434 assert!(md.contains("0.9999") && !md.contains("0.9999 ("), "{md}");
1435 assert!(!md.contains("(+0.000%)"), "{md}");
1436 }
1437
1438 #[test]
1439 fn test_market_summary_to_markdown_with_execution_estimates() {
1440 use scope::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1441 let summary = MarketSummary {
1442 pair: "TESTUSDT".to_string(),
1443 source: scope::market::BookSource::Exchange,
1444 peg_target: 1.0,
1445 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1446 best_bid: Some(0.9999),
1447 best_ask: Some(1.0001),
1448 mid_price: Some(1.0000),
1449 spread: Some(0.0002),
1450 spread_pct: None,
1451 top_depth: Default::default(),
1452 volume_24h: Some(1_000_000.0),
1453 bid_depth: 50_000.0,
1454 ask_depth: 50_000.0,
1455 bid_outliers: 0,
1456 ask_outliers: 0,
1457 healthy: true,
1458 bot_patterns: None,
1459 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1460 execution_10k_buy: Some(ExecutionEstimate {
1461 notional_usdt: 10_000.0,
1462 side: ExecutionSide::Buy,
1463 vwap: 1.0001,
1464 slippage_bps: 1.5,
1465 fillable: true,
1466 }),
1467 execution_10k_sell: Some(ExecutionEstimate {
1468 notional_usdt: 10_000.0,
1469 side: ExecutionSide::Sell,
1470 vwap: 0.0,
1471 slippage_bps: 0.0,
1472 fillable: false,
1473 }),
1474 asks: vec![],
1475 bids: vec![],
1476 };
1477 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1478 assert!(md.contains("Market Health Report"));
1479 assert!(md.contains("TESTUSDT"));
1480 assert!(md.contains("TestVenue"));
1481 assert!(md.contains("1.50 bps"));
1483 assert!(md.contains("insufficient"));
1485 }
1486
1487 #[tokio::test]
1488 async fn test_run_with_summary_command() {
1489 let args = MarketCommands::Summary(SummaryArgs {
1491 pair: "USDC".to_string(),
1492 venue: "eth".to_string(),
1493 token_contract: None,
1494 chain: None,
1495 health: HealthArgs {
1496 peg_target: Some(1.0),
1497 peg_range: Some(0.01),
1498 min_levels: Some(1),
1499 min_depth: Some(50.0),
1500 min_bid_ask_ratio: Some(0.1),
1501 max_bid_ask_ratio: Some(10.0),
1502 ..Default::default()
1503 },
1504 format: SummaryFormat::Text,
1505 every: None,
1506 duration: None,
1507 report: None,
1508 csv: None,
1509 });
1510
1511 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1512 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1513 let factory = DefaultClientFactory {
1514 chains_config: Default::default(),
1515 http,
1516 };
1517 let config = Config::default();
1518 let _result = run(args, &config, &factory, &quiet()).await;
1519 }
1521
1522 #[test]
1523 fn test_is_dex_venue() {
1524 assert!(DexVenue::parse("eth").is_some());
1525 assert!(DexVenue::parse("ethereum").is_some());
1526 assert!(DexVenue::parse("Ethereum").is_some());
1527 assert!(DexVenue::parse("solana").is_some());
1528 assert!(DexVenue::parse("Solana").is_some());
1529 assert!(DexVenue::parse("binance").is_none());
1530 assert!(DexVenue::parse("okx").is_none());
1531 assert!(DexVenue::parse("mexc").is_none());
1532 }
1533
1534 #[test]
1535 fn test_dex_venue_to_chain() {
1536 assert_eq!(
1537 DexVenue::parse("eth").map(DexVenue::chain),
1538 Some("ethereum")
1539 );
1540 assert_eq!(
1541 DexVenue::parse("ethereum").map(DexVenue::chain),
1542 Some("ethereum")
1543 );
1544 assert_eq!(
1545 DexVenue::parse("Ethereum").map(DexVenue::chain),
1546 Some("ethereum")
1547 );
1548 assert_eq!(
1549 DexVenue::parse("solana").map(DexVenue::chain),
1550 Some("solana")
1551 );
1552 }
1553
1554 #[test]
1555 fn test_venue_registry_loaded_in_cex_path() {
1556 let registry = VenueRegistry::load().unwrap();
1558 assert!(registry.contains("binance"));
1559 let client =
1560 registry.create_exchange_client_with_http("binance", scope::http::test_transport());
1561 assert!(client.is_ok());
1562 }
1563
1564 #[test]
1565 fn test_venue_registry_error_for_unknown() {
1566 let registry = VenueRegistry::load().unwrap();
1567 let result =
1568 registry.create_exchange_client_with_http("kracken", scope::http::test_transport());
1569 assert!(result.is_err());
1570 let err = result.unwrap_err().to_string();
1571 assert!(err.contains("Unknown venue"));
1572 assert!(err.contains("Did you mean")); }
1574
1575 #[tokio::test]
1576 async fn test_run_summary_json_format_with_mock() {
1577 let args = SummaryArgs {
1578 pair: "USDC".to_string(),
1579 venue: "eth".to_string(),
1580 token_contract: None,
1581 chain: None,
1582 health: HealthArgs {
1583 peg_target: Some(1.0),
1584 peg_range: Some(0.01),
1585 min_levels: Some(1),
1586 min_depth: Some(50.0),
1587 min_bid_ask_ratio: Some(0.1),
1588 max_bid_ask_ratio: Some(10.0),
1589 ..Default::default()
1590 },
1591 format: SummaryFormat::Json,
1592 every: None,
1593 duration: None,
1594 report: None,
1595 csv: None,
1596 };
1597
1598 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1599 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1600 let factory = DefaultClientFactory {
1601 chains_config: Default::default(),
1602 http,
1603 };
1604 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1605 }
1606
1607 #[test]
1612 fn test_ohlc_format_default() {
1613 let fmt: OhlcFormat = Default::default();
1614 assert_eq!(fmt, OhlcFormat::Text);
1615 }
1616
1617 #[test]
1618 fn test_ohlc_format_display() {
1619 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1621 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1622 }
1623
1624 #[test]
1625 fn test_ohlc_args_default_values() {
1626 let args = OhlcArgs {
1628 pair: "BTC".to_string(),
1629 venue: "binance".to_string(),
1630 token_contract: None,
1631 interval: "1h".to_string(),
1632 limit: Some(100),
1633 table: false,
1634 format: OhlcFormat::Text,
1635 };
1636 assert_eq!(args.pair, "BTC");
1637 assert_eq!(args.venue, "binance");
1638 assert_eq!(args.interval, "1h");
1639 assert_eq!(args.limit, Some(100));
1640 }
1641
1642 #[test]
1643 fn test_trades_args_construction() {
1644 let args = TradesArgs {
1645 pair: "ETH".to_string(),
1646 venue: "okx".to_string(),
1647 token_contract: None,
1648 limit: 50,
1649 format: OhlcFormat::Json,
1650 };
1651 assert_eq!(args.pair, "ETH");
1652 assert_eq!(args.venue, "okx");
1653 assert_eq!(args.limit, 50);
1654 }
1655
1656 #[tokio::test]
1657 async fn test_run_ohlc_unknown_venue() {
1658 let args = OhlcArgs {
1659 pair: "BTC".to_string(),
1660 venue: "nonexistent_venue".to_string(),
1661 token_contract: None,
1662 interval: "1h".to_string(),
1663 limit: Some(10),
1664 table: false,
1665 format: OhlcFormat::Text,
1666 };
1667 let result = run_ohlc(args, scope::http::test_transport(), &quiet()).await;
1668 assert!(result.is_err());
1669 let err = result.unwrap_err().to_string();
1670 assert!(
1671 err.contains("Unknown venue"),
1672 "expected 'Unknown venue' error, got: {}",
1673 err
1674 );
1675 }
1676
1677 #[tokio::test]
1678 async fn test_run_trades_unknown_venue() {
1679 let args = TradesArgs {
1680 pair: "BTC".to_string(),
1681 venue: "nonexistent_venue".to_string(),
1682 token_contract: None,
1683 limit: 10,
1684 format: OhlcFormat::Text,
1685 };
1686 let result = run_trades(args, scope::http::test_transport(), &quiet()).await;
1687 assert!(result.is_err());
1688 let err = result.unwrap_err().to_string();
1689 assert!(
1690 err.contains("Unknown venue"),
1691 "expected 'Unknown venue' error, got: {}",
1692 err
1693 );
1694 }
1695
1696 #[tokio::test]
1697 async fn test_run_dispatches_ohlc() {
1698 let cmd = MarketCommands::Ohlc(OhlcArgs {
1699 pair: "BTC".to_string(),
1700 venue: "nonexistent_test_venue".to_string(),
1701 token_contract: None,
1702 interval: "1h".to_string(),
1703 limit: Some(5),
1704 table: false,
1705 format: OhlcFormat::Text,
1706 });
1707 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1708 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1709 let factory = DefaultClientFactory {
1710 chains_config: Default::default(),
1711 http,
1712 };
1713 let config = Config::default();
1714 let result = run(cmd, &config, &factory, &quiet()).await;
1715 assert!(result.is_err());
1717 }
1718
1719 #[tokio::test]
1720 async fn test_run_dispatches_trades() {
1721 let cmd = MarketCommands::Trades(TradesArgs {
1722 pair: "ETH".to_string(),
1723 venue: "nonexistent_test_venue".to_string(),
1724 token_contract: None,
1725 limit: 5,
1726 format: OhlcFormat::Json,
1727 });
1728 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1729 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1730 let factory = DefaultClientFactory {
1731 chains_config: Default::default(),
1732 http,
1733 };
1734 let config = Config::default();
1735 let result = run(cmd, &config, &factory, &quiet()).await;
1736 assert!(result.is_err());
1737 }
1738
1739 fn venue_with_coins(server: &mockito::ServerGuard) -> ExchangeClient {
1741 let mut desc = VenueRegistry::load()
1742 .unwrap()
1743 .get("binance")
1744 .unwrap()
1745 .clone();
1746 desc.base_url = server.url();
1747 desc.capabilities.assets =
1748 Some(serde_saphyr::from_str("path: /coins\nresponse: {}\n").unwrap());
1749 ExchangeClient::from_descriptor_with_http(&desc, scope::http::test_transport())
1750 }
1751
1752 async fn coins(server: &mut mockito::ServerGuard, body: &str) -> mockito::Mock {
1753 server
1754 .mock("GET", "/coins")
1755 .with_status(200)
1756 .with_body(body)
1757 .create_async()
1758 .await
1759 }
1760
1761 #[tokio::test]
1762 async fn test_a_cex_run_stops_when_the_saved_token_is_not_the_venues() {
1763 let mut server = mockito::Server::new_async().await;
1766 let _m = coins(
1767 &mut server,
1768 r#"[{"coin":"USDN","networkList":[{"network":"ETH","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1769 )
1770 .await;
1771 let client = venue_with_coins(&server);
1772 let mut store = scope::tokens::TokenAliases::default();
1773 store.add_binding(
1774 "USDN",
1775 "ethereum",
1776 "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1777 "USDN",
1778 Some("binance"),
1779 );
1780 let err = check_cex_token(
1781 &client,
1782 "USDN",
1783 Some("ethereum".into()),
1784 None,
1785 &mut store,
1786 false,
1787 &quiet(),
1788 )
1789 .await
1790 .unwrap_err();
1791 assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1792 }
1793
1794 #[tokio::test]
1795 async fn test_a_cex_symbol_on_two_networks_stops_without_a_terminal() {
1796 let mut server = mockito::Server::new_async().await;
1797 let _m = coins(
1798 &mut server,
1799 r#"[{"coin":"USDN","networkList":[
1800 {"network":"ETH","contractAddress":"0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
1801 {"network":"BSC","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1802 )
1803 .await;
1804 let client = venue_with_coins(&server);
1805 let mut store = scope::tokens::TokenAliases::default();
1806 let err = check_cex_token(&client, "USDN", None, None, &mut store, false, &quiet())
1807 .await
1808 .unwrap_err();
1809 assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{err}");
1810 assert!(
1812 check_cex_token(
1813 &client,
1814 "USDN",
1815 None,
1816 Some("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into()),
1817 &mut store,
1818 false,
1819 &quiet()
1820 )
1821 .await
1822 .is_ok()
1823 );
1824 }
1825
1826 fn binance_at(server: &mockito::ServerGuard) -> ExchangeClient {
1828 scope::market::testing::venue_at("binance", &server.url())
1829 }
1830
1831 const KLINES: &str = r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999],
1832 [1700003600000,"1.25","2.0","1.0","1.75","20.25",1700007199999]]"#;
1833 const TRADES: &str = r#"[{"id":1,"price":"1.5","qty":"2.5","quoteQty":"3.75","time":1700000000000,"isBuyerMaker":true},
1834 {"id":2,"price":"1.6","qty":"1.0","quoteQty":"1.6","time":1700000001000,"isBuyerMaker":false}]"#;
1835
1836 fn ohlc_args(format: OhlcFormat) -> OhlcArgs {
1837 OhlcArgs {
1838 pair: "BTC".to_string(),
1839 venue: "binance".to_string(),
1840 token_contract: None,
1841 interval: "1h".to_string(),
1842 limit: Some(2),
1843 table: false,
1844 format,
1845 }
1846 }
1847
1848 fn trades_args(format: OhlcFormat) -> TradesArgs {
1849 TradesArgs {
1850 pair: "BTC".to_string(),
1851 venue: "binance".to_string(),
1852 token_contract: None,
1853 limit: 2,
1854 format,
1855 }
1856 }
1857
1858 async fn serve(path: &str, body: &str) -> mockito::ServerGuard {
1859 let mut server = mockito::Server::new_async().await;
1860 server
1861 .mock("GET", path)
1862 .match_query(mockito::Matcher::UrlEncoded(
1863 "symbol".into(),
1864 "BTCUSDT".into(),
1865 ))
1866 .with_body(body)
1867 .create_async()
1868 .await;
1869 server
1870 }
1871
1872 #[tokio::test]
1873 async fn test_ohlc_text_is_a_chart_and_a_summary() {
1874 let server = serve("/api/v3/klines", KLINES).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!(text.contains("OHLC — BTCUSDT (binance)"), "{text}");
1881 assert!(text.contains("2.000000 ┤"), "{text}");
1883 assert!(text.contains("0.500000 ┤"), "{text}");
1884 assert!(text.contains('█'), "{text}");
1885 assert!(text.contains("1h · 2 candles"), "{text}");
1887 assert!(text.contains("1.7500 (+75.00%)"), "{text}");
1888 assert!(text.contains("1.02K"), "volume 1000.5 + 20.25: {text}");
1889 assert!(!text.contains("candles returned"), "{text}");
1891 }
1892
1893 #[tokio::test]
1894 async fn test_ohlc_table_adds_every_candle() {
1895 let server = serve("/api/v3/klines", KLINES).await;
1896 let (out, cap) = Output::capture();
1897 let args = OhlcArgs {
1898 table: true,
1899 ..ohlc_args(OhlcFormat::Text)
1900 };
1901 run_ohlc_with(&binance_at(&server), args, &out)
1902 .await
1903 .unwrap();
1904 let text = crate::cli::ansi::strip(&cap.out());
1905 assert!(text.contains("1.250000"), "{text}");
1906 assert!(text.contains("1000.50"), "{text}");
1907 assert!(text.contains("2 candles returned"), "{text}");
1908 }
1909
1910 #[tokio::test]
1911 async fn test_ohlc_reads_newest_first_venues_oldest_first() {
1912 const REVERSED: &str = r#"[[1700003600000,"1.25","2.0","1.0","1.75","20.25",1700007199999],
1914 [1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999]]"#;
1915 let server = serve("/api/v3/klines", REVERSED).await;
1916 let (out, cap) = Output::capture();
1917 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Text), &out)
1918 .await
1919 .unwrap();
1920 let text = crate::cli::ansi::strip(&cap.out());
1921 assert!(
1922 text.contains("1.7500 (+75.00%)"),
1923 "open is the oldest: {text}"
1924 );
1925 }
1926
1927 #[tokio::test]
1928 async fn test_ohlc_json_keeps_every_field() {
1929 let server = serve("/api/v3/klines", KLINES).await;
1930 let (out, cap) = Output::capture();
1931 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Json), &out)
1932 .await
1933 .unwrap();
1934 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1935 assert_eq!(v.as_array().unwrap().len(), 2);
1936 assert_eq!(v[0]["open_time"], 1_700_000_000_000u64);
1937 assert_eq!(v[1]["close"], 1.75);
1938 assert_eq!(v[1]["volume"], 20.25);
1939 }
1940
1941 #[tokio::test]
1942 async fn test_trades_text_shows_side_price_and_quantity() {
1943 let server = serve("/api/v3/trades", TRADES).await;
1944 let (out, cap) = Output::capture();
1945 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Text), &out)
1946 .await
1947 .unwrap();
1948 let text = crate::cli::ansi::strip(&cap.out());
1949 assert!(text.contains("Recent Trades — BTCUSDT (binance)"), "{text}");
1950 assert!(text.contains("SELL") && text.contains("BUY"), "{text}");
1952 assert!(text.contains("1.500000") && text.contains("2.50"), "{text}");
1953 assert!(text.contains("2 trades returned"), "{text}");
1954 }
1955
1956 #[tokio::test]
1957 async fn test_trades_json_keeps_every_field() {
1958 let server = serve("/api/v3/trades", TRADES).await;
1959 let (out, cap) = Output::capture();
1960 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Json), &out)
1961 .await
1962 .unwrap();
1963 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1964 assert_eq!(v[0]["side"], "sell");
1965 assert_eq!(v[1]["side"], "buy");
1966 assert_eq!(v[0]["id"], "1");
1967 assert_eq!(v[0]["quote_quantity"], 3.75);
1968 assert_eq!(v[1]["timestamp_ms"], 1_700_000_001_000u64);
1969 }
1970
1971 #[test]
1976 fn test_parse_duration_whitespace_empty() {
1977 assert!(parse_duration("").is_err());
1978 assert!(parse_duration(" ").is_err());
1979 }
1980
1981 #[test]
1982 fn test_parse_duration_sec_secs_second_seconds() {
1983 assert_eq!(parse_duration("1sec").unwrap(), 1);
1984 assert_eq!(parse_duration("2secs").unwrap(), 2);
1985 assert_eq!(parse_duration("1second").unwrap(), 1);
1986 assert_eq!(parse_duration("3seconds").unwrap(), 3);
1987 }
1988
1989 #[test]
1990 fn test_parse_duration_minute_minutes() {
1991 assert_eq!(parse_duration("1minute").unwrap(), 60);
1992 assert_eq!(parse_duration("2minutes").unwrap(), 120);
1993 }
1994
1995 #[test]
1996 fn test_parse_duration_hr_hrs_hour_hours() {
1997 assert_eq!(parse_duration("1hr").unwrap(), 3600);
1998 assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1999 assert_eq!(parse_duration("1hour").unwrap(), 3600);
2000 assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
2001 }
2002
2003 #[test]
2004 fn test_parse_duration_number_only_defaults_to_seconds() {
2005 assert_eq!(parse_duration("1.5").unwrap(), 1);
2006 assert_eq!(parse_duration("42").unwrap(), 42);
2007 }
2008
2009 #[test]
2010 fn test_parse_duration_trimmed_input() {
2011 assert_eq!(parse_duration(" 30s ").unwrap(), 30);
2012 assert_eq!(parse_duration(" 5m ").unwrap(), 300);
2013 }
2014
2015 #[test]
2016 fn test_parse_duration_invalid_number_format() {
2017 assert!(parse_duration("1.2.3s").is_err());
2018 assert!(parse_duration("abc").is_err());
2019 }
2020
2021 #[test]
2026 fn test_unknown_venue_is_not_a_dex_venue() {
2027 assert_eq!(DexVenue::parse("binance"), None);
2028 assert_eq!(DexVenue::parse("kraken"), None);
2029 assert_eq!(DexVenue::parse("unknown"), None);
2030 }
2031
2032 #[test]
2037 fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
2038 use scope::market::{HealthCheck, MarketSummary};
2039 let summary = MarketSummary {
2040 pair: "TESTUSDT".to_string(),
2041 source: scope::market::BookSource::Exchange,
2042 peg_target: 1.0,
2043 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
2044 best_bid: Some(0.9999),
2045 best_ask: Some(1.0001),
2046 mid_price: Some(1.0000),
2047 spread: Some(0.0002),
2048 spread_pct: None,
2049 top_depth: Default::default(),
2050 volume_24h: Some(500_000.0),
2051 bid_depth: 40_000.0,
2052 ask_depth: 45_000.0,
2053 bid_outliers: 0,
2054 ask_outliers: 0,
2055 healthy: false,
2056 bot_patterns: None,
2057 checks: vec![
2058 HealthCheck::Pass("Spread within range".to_string()),
2059 HealthCheck::Fail("Bid depth below minimum".to_string()),
2060 ],
2061 execution_10k_buy: None,
2062 execution_10k_sell: None,
2063 asks: vec![],
2064 bids: vec![],
2065 };
2066 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
2067 assert!(md.contains("✓ Spread within range"));
2068 assert!(md.contains("✗ Bid depth below minimum"));
2069 assert!(md.contains("✗")); }
2071
2072 #[test]
2073 fn test_market_summary_to_markdown_empty_checks() {
2074 use scope::market::MarketSummary;
2075 let summary = MarketSummary {
2076 pair: "X".to_string(),
2077 source: scope::market::BookSource::Exchange,
2078 peg_target: 1.0,
2079 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
2080 best_bid: Some(1.0),
2081 best_ask: Some(1.0),
2082 mid_price: Some(1.0),
2083 spread: Some(0.0),
2084 spread_pct: None,
2085 top_depth: Default::default(),
2086 volume_24h: None,
2087 bid_depth: 100.0,
2088 ask_depth: 100.0,
2089 bid_outliers: 0,
2090 ask_outliers: 0,
2091 healthy: true,
2092 bot_patterns: None,
2093 checks: vec![],
2094 execution_10k_buy: None,
2095 execution_10k_sell: None,
2096 asks: vec![],
2097 bids: vec![],
2098 };
2099 let md = market_summary_to_markdown(&summary, "Venue", "X");
2100 assert!(md.contains("Market Health Report"));
2101 assert!(!md.contains("Health Checks:"));
2103 }
2104
2105 #[test]
2110 fn test_ohlc_format_partial_eq() {
2111 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
2112 assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
2113 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
2114 }
2115
2116 #[test]
2117 fn test_summary_format_clone_copy() {
2118 let text = SummaryFormat::Text;
2119 let cloned = text;
2120 assert!(matches!(cloned, SummaryFormat::Text));
2121 assert!(matches!(text, SummaryFormat::Text));
2122 }
2123
2124 #[tokio::test]
2129 async fn test_run_summary_interval_zero_error() {
2130 let args = SummaryArgs {
2131 pair: "USDC".to_string(),
2132 venue: "eth".to_string(),
2133 token_contract: None,
2134 chain: None,
2135 health: HealthArgs {
2136 peg_target: Some(1.0),
2137 peg_range: Some(0.01),
2138 min_levels: Some(1),
2139 min_depth: Some(50.0),
2140 min_bid_ask_ratio: Some(0.1),
2141 max_bid_ask_ratio: Some(10.0),
2142 ..Default::default()
2143 },
2144 format: SummaryFormat::Text,
2145 every: Some("0.1s".to_string()),
2146 duration: Some("1m".to_string()),
2147 report: None,
2148 csv: None,
2149 };
2150 let http: std::sync::Arc<dyn scope::http::HttpClient> =
2151 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
2152 let factory = DefaultClientFactory {
2153 chains_config: Default::default(),
2154 http,
2155 };
2156 let result = run_summary(args, &Config::default(), &factory, &quiet()).await;
2157 assert!(result.is_err());
2158 let err = result.unwrap_err().to_string();
2159 assert!(
2160 err.contains("Interval must be positive") || err.contains("positive"),
2161 "expected interval error, got: {}",
2162 err
2163 );
2164 }
2165
2166 #[test]
2171 fn test_market_commands_debug() {
2172 let cmd = MarketCommands::Summary(SummaryArgs {
2173 pair: "USDC".to_string(),
2174 venue: "binance".to_string(),
2175 token_contract: None,
2176 chain: None,
2177 health: HealthArgs {
2178 peg_target: Some(1.0),
2179 peg_range: Some(0.001),
2180 min_levels: Some(6),
2181 min_depth: Some(3000.0),
2182 min_bid_ask_ratio: Some(0.2),
2183 max_bid_ask_ratio: Some(5.0),
2184 ..Default::default()
2185 },
2186 format: SummaryFormat::Text,
2187 every: None,
2188 duration: None,
2189 report: None,
2190 csv: None,
2191 });
2192 let debug = format!("{:?}", cmd);
2193 assert!(debug.contains("Summary"));
2194
2195 let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
2196 pair: "BTC".to_string(),
2197 venue: "binance".to_string(),
2198 token_contract: None,
2199 interval: "1h".to_string(),
2200 limit: Some(100),
2201 table: false,
2202 format: OhlcFormat::Text,
2203 });
2204 assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
2205
2206 let trades_cmd = MarketCommands::Trades(TradesArgs {
2207 pair: "ETH".to_string(),
2208 venue: "binance".to_string(),
2209 token_contract: None,
2210 limit: 50,
2211 format: OhlcFormat::Json,
2212 });
2213 assert!(format!("{:?}", trades_cmd).contains("Trades"));
2214 }
2215
2216 #[test]
2217 fn test_summary_args_with_report_csv_options() {
2218 let args = SummaryArgs {
2219 pair: "DAI".to_string(),
2220 venue: "binance".to_string(),
2221 token_contract: None,
2222 chain: None,
2223 health: HealthArgs {
2224 peg_target: Some(1.0),
2225 peg_range: Some(0.001),
2226 min_levels: Some(6),
2227 min_depth: Some(3000.0),
2228 min_bid_ask_ratio: Some(0.2),
2229 max_bid_ask_ratio: Some(5.0),
2230 ..Default::default()
2231 },
2232 format: SummaryFormat::Json,
2233 every: Some("30s".to_string()),
2234 duration: Some("1h".to_string()),
2235 report: Some(std::path::PathBuf::from("/tmp/report.md")),
2236 csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
2237 };
2238 assert_eq!(args.pair, "DAI");
2239 assert_eq!(args.venue, "binance");
2240 assert!(args.every.is_some());
2241 assert!(args.duration.is_some());
2242 assert!(args.report.is_some());
2243 assert!(args.csv.is_some());
2244 }
2245
2246 #[test]
2247 fn test_base_symbol_from_pair_4char_usdt() {
2248 assert_eq!(Pair::parse("USDT").base, "USDT");
2250 }
2251
2252 #[test]
2253 fn test_market_summary_to_markdown_unhealthy() {
2254 use scope::market::{HealthCheck, MarketSummary};
2255 let summary = MarketSummary {
2256 pair: "X".to_string(),
2257 source: scope::market::BookSource::Exchange,
2258 peg_target: 1.0,
2259 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
2260 best_bid: Some(0.99),
2261 best_ask: Some(1.01),
2262 mid_price: Some(1.0),
2263 spread: Some(0.02),
2264 spread_pct: None,
2265 top_depth: Default::default(),
2266 volume_24h: None,
2267 bid_depth: 100.0,
2268 ask_depth: 100.0,
2269 bid_outliers: 0,
2270 ask_outliers: 0,
2271 healthy: false,
2272 bot_patterns: None,
2273 checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
2274 execution_10k_buy: None,
2275 execution_10k_sell: None,
2276 asks: vec![],
2277 bids: vec![],
2278 };
2279 let md = market_summary_to_markdown(&summary, "Test", "X");
2280 assert!(md.contains("✗"));
2281 assert!(md.contains("Peg deviation too high"));
2282 }
2283
2284 #[test]
2285 fn test_ohlc_format_eq() {
2286 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
2287 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
2288 }
2289
2290 #[test]
2291 fn test_trades_args_default_venue() {
2292 let args = TradesArgs {
2293 pair: "USDC".to_string(),
2294 venue: "binance".to_string(),
2295 token_contract: None,
2296 limit: 50,
2297 format: OhlcFormat::Text,
2298 };
2299 assert_eq!(args.pair, "USDC");
2300 assert_eq!(args.venue, "binance");
2301 }
2302
2303 const USDC_ADDR: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
2308
2309 fn dex_factory(pairs: &[(f64, f64)]) -> scope::chains::mocks::MockClientFactory {
2311 let mut factory = scope::chains::mocks::MockClientFactory::new();
2312 let data = factory.mock_dex.token_data.as_mut().unwrap();
2313 data.symbol = "USDC".to_string();
2314 data.pairs = pairs
2315 .iter()
2316 .enumerate()
2317 .map(|(i, &(liquidity_usd, volume_24h))| scope::chains::DexPair {
2318 dex_name: format!("DEX {i}"),
2319 pair_address: format!("0xpair{i}"),
2320 base_token: "USDC".to_string(),
2321 quote_token: "USDT".to_string(),
2322 price_usd: 1.0,
2323 volume_24h,
2324 liquidity_usd,
2325 liquidity_base: None,
2326 price_change_24h: 0.0,
2327 buys_24h: 0,
2328 sells_24h: 0,
2329 buys_6h: 0,
2330 sells_6h: 0,
2331 buys_1h: 0,
2332 sells_1h: 0,
2333 pair_created_at: None,
2334 url: None,
2335 })
2336 .collect();
2337 factory
2338 }
2339
2340 fn dex_args(format: SummaryFormat) -> SummaryArgs {
2341 SummaryArgs {
2342 pair: USDC_ADDR.to_string(),
2343 venue: "eth".to_string(),
2344 token_contract: None,
2345 chain: None,
2346 health: HealthArgs::default(),
2347 format,
2348 every: None,
2349 duration: None,
2350 report: None,
2351 csv: None,
2352 }
2353 }
2354
2355 fn two_chain_usdn(factory: &mut scope::chains::mocks::MockClientFactory) {
2356 let usdn = |chain: &str, address: &str| scope::chains::dex::TokenSearchResult {
2357 address: address.into(),
2358 symbol: "USDN".into(),
2359 name: "USDN".into(),
2360 chain: chain.into(),
2361 price_usd: Some(1.0),
2362 volume_24h: 0.0,
2363 liquidity_usd: 1.0,
2364 market_cap: None,
2365 };
2366 factory.mock_dex.search_results = vec![usdn("bsc", "0xB"), usdn("ethereum", "0xA")];
2367 }
2368
2369 #[tokio::test]
2370 async fn test_a_dex_venue_resolves_on_its_own_chain() {
2371 let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2373 two_chain_usdn(&mut factory);
2374 let mut args = dex_args(SummaryFormat::Text);
2375 args.pair = "USDN".into();
2376 let token = resolve_market_token(&args, &factory, &quiet())
2377 .await
2378 .unwrap();
2379 assert_eq!(token.as_deref(), Some("0xA"));
2380 }
2381
2382 #[tokio::test]
2383 async fn test_a_dex_venue_takes_the_token_flag_without_a_search() {
2384 let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2386 two_chain_usdn(&mut factory);
2387 let mut args = dex_args(SummaryFormat::Text);
2388 args.pair = "USDN".into();
2389 args.token_contract = Some("0xT".into());
2390 let token = resolve_market_token(&args, &factory, &quiet())
2391 .await
2392 .unwrap();
2393 assert_eq!(token.as_deref(), Some("0xT"));
2394 }
2395
2396 #[tokio::test]
2397 async fn test_dex_book_uses_most_liquid_pair() {
2398 let factory = dex_factory(&[(1_000.0, 10.0), (50_000.0, 777.0), (2_000.0, 20.0)]);
2400 let (book, volume, trades) = fetch_book_and_volume(
2401 &dex_args(SummaryFormat::Text),
2402 &HealthThresholds::default(),
2403 &factory,
2404 &quiet(),
2405 )
2406 .await
2407 .unwrap();
2408 assert_eq!(volume, Some(777.0));
2409 assert_eq!(trades.unwrap_err(), "a DEX pool has no trade tape here");
2411 let depth = book.total_depth(scope::market::BookSide::Bid)
2412 + book.total_depth(scope::market::BookSide::Ask);
2413 assert!((depth - 50_000.0).abs() < 1.0, "depth {depth}");
2414 }
2415
2416 #[tokio::test]
2419 async fn test_venue_sol_reads_the_solana_dex() {
2420 let factory = dex_factory(&[(50_000.0, 777.0)]);
2421 let mut args = dex_args(SummaryFormat::Text);
2422 args.venue = "sol".to_string();
2423 let result =
2424 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet()).await;
2425 let (book, volume, _trades) = result.unwrap();
2426 assert_eq!(volume, Some(777.0));
2427 assert_eq!(book.pair, "USDC/USD");
2428 }
2429
2430 #[tokio::test]
2433 async fn test_chain_must_match_the_dex_venue() {
2434 let factory = dex_factory(&[(50_000.0, 1.0)]);
2435 let mut args = dex_args(SummaryFormat::Text);
2436 args.chain = Some("Solana".to_string());
2437 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2438 .await
2439 .unwrap_err();
2440 assert!(err.to_string().contains("reads chain ethereum"), "{err}");
2441
2442 args.chain = Some("ETH".to_string());
2443 assert!(
2444 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2445 .await
2446 .is_ok()
2447 );
2448 }
2449
2450 #[tokio::test]
2451 async fn test_unknown_venue_is_an_error() {
2452 let factory = dex_factory(&[(50_000.0, 1.0)]);
2453 let mut args = dex_args(SummaryFormat::Text);
2454 args.venue = "polygon".to_string();
2455 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2456 .await
2457 .unwrap_err();
2458 assert!(
2459 err.to_string().contains("Unknown venue \"polygon\""),
2460 "{err}"
2461 );
2462 }
2463
2464 #[tokio::test]
2465 async fn test_dex_no_pairs_is_error() {
2466 let factory = dex_factory(&[]);
2467 let err = fetch_book_and_volume(
2468 &dex_args(SummaryFormat::Text),
2469 &HealthThresholds::default(),
2470 &factory,
2471 &quiet(),
2472 )
2473 .await
2474 .unwrap_err();
2475 assert!(err.to_string().contains("No DEX pairs found"), "{err}");
2476 }
2477
2478 #[tokio::test]
2479 async fn test_a_repeat_adds_the_depth_saw_tooth_to_the_bot_patterns() {
2480 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2481 let args = dex_args(SummaryFormat::Text);
2482 let thresholds = HealthThresholds::default();
2483 let (base, _) = summary_run(&args, &factory, &thresholds, &[], None, None, &quiet())
2484 .await
2485 .unwrap();
2486 let history = DepthHistory::default();
2487 let mut last = base.clone();
2488 for i in 0..120i64 {
2490 last = base.clone();
2491 last.ask_depth = 8_000.0 * 0.95f64.powi((i % 29) as i32);
2492 history.record_at(&mut last, i * 30_000);
2493 }
2494 let found = last.bot_patterns.as_ref().unwrap();
2495 assert!(
2496 found.findings.iter().any(|f| f.describe()
2497 == "Ask depth cycle: saw-tooth: drains, then refills every 14m 30s (3 cycles)"),
2498 "{:?}",
2499 found.findings
2500 );
2501 assert!(
2502 last.format_text(None).contains("Ask depth cycle"),
2503 "{}",
2504 last.format_text(None)
2505 );
2506 }
2507
2508 #[tokio::test]
2509 async fn test_run_summary_once_json_reflects_resolved_thresholds() {
2510 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2511 let mut args = dex_args(SummaryFormat::Json);
2512 args.health = HealthArgs {
2514 min_levels: Some(1),
2515 min_top10_depth: Some(1.0),
2516 ..Default::default()
2517 };
2518 let thresholds = args.health.resolve(&Config::default());
2519 let (summary, _) = summary_run(&args, &factory, &thresholds, &[], Some(1), None, &quiet())
2520 .await
2521 .unwrap();
2522 assert!(summary.healthy, "{:?}", summary.checks);
2523
2524 let strict = HealthThresholds::default();
2525 let (summary, _) = summary_run(&args, &factory, &strict, &[], None, None, &quiet())
2526 .await
2527 .unwrap();
2528 assert!(!summary.healthy);
2529 }
2530
2531 #[tokio::test]
2532 async fn test_dex_json_shows_amm_source_and_na_checks() {
2533 let mut factory = dex_factory(&[(20_000_000.0, 1_000.0)]);
2535 factory.mock_dex.token_data.as_mut().unwrap().pairs[0].liquidity_base = Some(10_000_000.0);
2536 let args = dex_args(SummaryFormat::Json);
2537 let thresholds = args.health.resolve(&Config::default());
2538 let (summary, report) =
2539 summary_run(&args, &factory, &thresholds, &[], None, None, &quiet())
2540 .await
2541 .unwrap();
2542 assert!(summary.healthy, "{:?}", summary.checks);
2543 let v: serde_json::Value = serde_json::from_str(&report).unwrap();
2544 assert_eq!(v["book_source"], "synthetic_amm");
2545 let statuses: Vec<&str> = v["checks"]
2546 .as_array()
2547 .unwrap()
2548 .iter()
2549 .map(|c| c["status"].as_str().unwrap())
2550 .collect();
2551 assert_eq!(
2554 statuses.iter().filter(|s| **s == "n/a").count(),
2555 4,
2556 "{:?}",
2557 statuses
2558 );
2559 assert!(!statuses.contains(&"fail"));
2560 assert_eq!(
2561 v["checks"].as_array().unwrap().last().unwrap()["message"],
2562 "Bot patterns: not checked (a DEX pool has no trade tape here)"
2563 );
2564 assert_eq!(v["bot_patterns"]["book_checked"], false);
2565 }
2566
2567 #[tokio::test]
2568 async fn test_dex_without_reserves_says_estimate() {
2569 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2570 let args = dex_args(SummaryFormat::Text);
2571 let thresholds = args.health.resolve(&Config::default());
2572 let (_, report) = summary_run(&args, &factory, &thresholds, &[], None, None, &quiet())
2573 .await
2574 .unwrap();
2575 assert!(report.contains("pool reserves not reported"), "{report}");
2576 }
2577
2578 #[tokio::test]
2579 async fn test_run_summary_one_shot_writes_report() {
2580 let dir = tempfile::tempdir().unwrap();
2581 let report = dir.path().join("report.md");
2582 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2583 let mut args = dex_args(SummaryFormat::Text);
2584 args.report = Some(report.clone());
2585 run_summary(args, &Config::default(), &factory, &quiet())
2586 .await
2587 .unwrap();
2588 let md = std::fs::read_to_string(&report).unwrap();
2589 assert!(md.contains("Market Health Report"), "{md}");
2590 assert!(md.contains(USDC_ADDR), "{md}");
2591 }
2592
2593 #[tokio::test(start_paused = true)]
2596 async fn test_run_summary_repeat_mode_writes_csv_rows_and_report() {
2597 let dir = tempfile::tempdir().unwrap();
2598 let csv = dir.path().join("series.csv");
2599 let report = dir.path().join("final.md");
2600 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2601 let mut args = dex_args(SummaryFormat::Json);
2602 args.every = Some("1s".to_string());
2603 args.duration = Some("1s".to_string());
2604 args.csv = Some(csv.clone());
2605 args.report = Some(report.clone());
2606 run_summary(args, &Config::default(), &factory, &quiet())
2607 .await
2608 .unwrap();
2609
2610 let body = std::fs::read_to_string(&csv).unwrap();
2611 let mut lines = body.lines();
2612 let header = lines.next().unwrap();
2614 assert!(
2615 header.starts_with(
2616 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy,"
2617 ),
2618 "{header}"
2619 );
2620 assert!(
2621 header.contains("spread_pct") && header.ends_with("ask_top10_depth"),
2622 "{header}"
2623 );
2624 let rows: Vec<_> = lines.collect();
2626 assert_eq!(rows.len(), 2, "{body}");
2627 assert!(rows[0].split(',').nth(1) == Some("1"));
2628 assert!(rows[1].split(',').nth(1) == Some("2"));
2629 assert!(report.exists());
2630 }
2631
2632 #[tokio::test(start_paused = true)]
2635 async fn test_run_summary_repeat_publishes_run_progress() {
2636 let (out, captured) = Output::capture_merged();
2639 let hook = out.progress_hook().expect("a merged capture has a hook");
2640 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2641 let mut args = dex_args(SummaryFormat::Json);
2642 args.every = Some("1s".to_string());
2643 args.duration = Some("2s".to_string());
2644 run_summary(args, &Config::default(), &factory, &out)
2645 .await
2646 .unwrap();
2647 let p = hook.lock().unwrap().clone();
2648 assert_eq!(p.total, Some(3), "{p:?}");
2650 assert_eq!(p.run, 3, "{p:?}");
2651 assert_eq!(p.next_at, None, "no next run after the last: {p:?}");
2652 assert!(captured.out().contains("every 1s for 2s"));
2653 assert!(captured.out().contains("Completed 3 run(s)"));
2654 }
2655
2656 #[tokio::test]
2657 async fn test_a_failed_run_ends_a_repeating_summary() {
2658 let (out, _captured) = Output::capture_merged();
2661 let hook = out.progress_hook().unwrap();
2662 let factory = dex_factory(&[]);
2663 let mut args = dex_args(SummaryFormat::Json);
2664 args.every = Some("1s".to_string());
2665 args.duration = Some("30s".to_string());
2666 let started = std::time::Instant::now();
2667 let result = run_summary(args, &Config::default(), &factory, &out).await;
2668 assert!(result.is_err(), "no DEX pairs is a failed run");
2669 assert_eq!(hook.lock().unwrap().run, 1, "no second run after the error");
2670 assert!(
2671 started.elapsed() < Duration::from_secs(5),
2672 "it did not sleep on"
2673 );
2674 }
2675
2676 fn hourly(n: u64, step_ms: u64) -> Vec<scope::market::Candle> {
2677 (0..n)
2678 .map(|i| scope::market::Candle {
2679 open_time: 1_700_000_000_000 + i * step_ms,
2680 open: 1.0,
2681 high: 2.0,
2682 low: 0.5,
2683 close: 1.5,
2684 volume: 1.0,
2685 close_time: 0,
2686 })
2687 .collect()
2688 }
2689
2690 #[test]
2691 fn test_more_candles_than_columns_says_the_chart_shows_the_last() {
2692 let (out, cap) = Output::capture();
2693 write_candle_chart(&hourly(200, 3_600_000), "1h", 60, &out).unwrap();
2694 let text = crate::cli::ansi::strip(&cap.out());
2695 assert!(
2696 text.contains("1h · 200 candles (the chart shows the last"),
2697 "{text}"
2698 );
2699 }
2700
2701 #[test]
2702 fn test_daily_candles_read_as_dates_and_no_candles_say_so() {
2703 let (out, cap) = Output::capture();
2704 write_candle_chart(&hourly(40, 86_400_000), "1d", 120, &out).unwrap();
2705 let text = crate::cli::ansi::strip(&cap.out());
2706 assert!(text.contains("2023-11-"), "dates, no hours: {text}");
2707 assert!(!text.contains(":00 "), "{text}");
2708 let (out, cap) = Output::capture();
2709 write_candle_chart(&[], "1h", 120, &out).unwrap();
2710 assert!(cap.out().contains("No candles returned"), "{}", cap.out());
2711 }
2712}