Skip to main content

scope/cli/
market.rs

1//! # Market Command
2//!
3//! Reports peg and order book health for stablecoin markets.
4//! Fetches level-2 depth from CEX (Binance, Biconomy) or DEX (Ethereum, Solana) venues
5//! and runs configurable health checks including volume and execution estimates.
6//! Supports one-shot or repeated runs with configurable frequency and duration.
7
8use crate::chains::ChainClientFactory;
9use crate::cli::crawl::{self, Period};
10use crate::config::Config;
11use crate::error::{Result, ScopeError};
12use crate::market::{
13    HealthThresholds, MarketSummary, OrderBook, VenueRegistry, order_book_from_analytics,
14};
15use clap::{Args, Subcommand};
16use std::time::Duration;
17
18/// Default interval between summary runs when in repeat mode (60 seconds).
19pub const DEFAULT_EVERY_SECS: u64 = 60;
20
21/// Default total duration when in repeat mode (1 hour).
22pub const DEFAULT_DURATION_SECS: u64 = 3600;
23
24/// Market subcommands.
25#[derive(Debug, Subcommand)]
26pub enum MarketCommands {
27    /// One-screen peg and order book health summary.
28    ///
29    /// Displays best bid/ask, mid price, spread, order book levels,
30    /// and configurable health checks (peg safety, bid/ask ratio, depth thresholds).
31    ///
32    /// Use --every and --duration to run repeatedly (e.g., every 30s for 1 hour).
33    Summary(SummaryArgs),
34
35    /// Fetch OHLC/candlestick (kline) data from a CEX venue.
36    Ohlc(OhlcArgs),
37
38    /// Fetch recent trades from a CEX venue.
39    Trades(TradesArgs),
40}
41
42/// Arguments for `scope market summary`.
43///
44/// Default thresholds (min_levels, min_depth, peg_range) originated from the
45/// PUSD Hummingbot market-making config and are tunable for other markets.
46#[derive(Debug, Args)]
47pub struct SummaryArgs {
48    /// Base token symbol (e.g., USDC, PUSD). Quote is USDT.
49    #[arg(default_value = "USDC", value_name = "SYMBOL")]
50    pub pair: String,
51
52    /// Market venue (e.g., binance, biconomy, mexc, okx, eth, solana).
53    /// Use `scope venues list` to see all available venues.
54    #[arg(long, default_value = "binance", value_name = "VENUE")]
55    pub venue: String,
56
57    /// Chain for DEX venues (ethereum or solana). Ignored for CEX.
58    #[arg(long, default_value = "ethereum", value_name = "CHAIN")]
59    pub chain: String,
60
61    /// Peg target (e.g., 1.0 for USD stablecoins).
62    #[arg(long, default_value = "1.0", value_name = "TARGET")]
63    pub peg: f64,
64
65    /// Minimum order book levels per side (default from PUSD Hummingbot config).
66    #[arg(long, default_value = "6", value_name = "N")]
67    pub min_levels: usize,
68
69    /// Minimum depth per side in quote terms, e.g. USDT (default from PUSD Hummingbot config).
70    #[arg(long, default_value = "3000", value_name = "USDT")]
71    pub min_depth: f64,
72
73    /// Peg range for outlier filtering (orders outside peg ± range×5 excluded).
74    /// E.g., 0.001 = ±0.5% around peg.
75    #[arg(long, default_value = "0.001", value_name = "RANGE")]
76    pub peg_range: f64,
77
78    /// Min bid/ask depth ratio (warn if ratio below this).
79    #[arg(long, default_value = "0.2", value_name = "RATIO")]
80    pub min_bid_ask_ratio: f64,
81
82    /// Max bid/ask depth ratio (warn if ratio above this).
83    #[arg(long, default_value = "5.0", value_name = "RATIO")]
84    pub max_bid_ask_ratio: f64,
85
86    /// Output format.
87    #[arg(short, long, default_value = "text")]
88    pub format: SummaryFormat,
89
90    /// Run repeatedly at this interval (e.g., 30s, 5m, 1h).
91    /// Default when in repeat mode: 60s.
92    #[arg(long, value_name = "INTERVAL")]
93    pub every: Option<String>,
94
95    /// Run for this total duration (e.g., 10m, 1h, 24h).
96    /// Default when in repeat mode: 1h.
97    #[arg(long, value_name = "DURATION")]
98    pub duration: Option<String>,
99
100    /// Save markdown report to file (one-shot mode) or final report (repeat mode).
101    #[arg(long, value_name = "PATH")]
102    pub report: Option<std::path::PathBuf>,
103
104    /// Append time-series CSV of peg/spread/depth to this path (repeat mode only).
105    #[arg(long, value_name = "PATH")]
106    pub csv: Option<std::path::PathBuf>,
107}
108
109#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
110pub enum SummaryFormat {
111    /// Human-readable text report (default).
112    #[default]
113    Text,
114    /// JSON for programmatic consumption.
115    Json,
116}
117
118/// Arguments for `scope market ohlc`.
119#[derive(Debug, Args)]
120pub struct OhlcArgs {
121    /// Trading pair symbol (e.g., USDC, BTC). Quote is USDT by default.
122    #[arg(default_value = "USDC", value_name = "SYMBOL")]
123    pub pair: String,
124
125    /// Exchange venue (e.g., binance, mexc, bybit).
126    #[arg(long, default_value = "binance", value_name = "VENUE")]
127    pub venue: String,
128
129    /// Candle interval (e.g., 1m, 5m, 15m, 1h, 4h, 1d).
130    #[arg(long, default_value = "1h", value_name = "INTERVAL")]
131    pub interval: String,
132
133    /// Maximum number of candles to fetch.
134    #[arg(long, default_value = "100", value_name = "LIMIT")]
135    pub limit: u32,
136
137    /// Output format.
138    #[arg(long, default_value = "text")]
139    pub format: OhlcFormat,
140}
141
142/// Arguments for `scope market trades`.
143#[derive(Debug, Args)]
144pub struct TradesArgs {
145    /// Trading pair symbol (e.g., USDC, BTC). Quote is USDT by default.
146    #[arg(default_value = "USDC", value_name = "SYMBOL")]
147    pub pair: String,
148
149    /// Exchange venue (e.g., binance, mexc, bybit).
150    #[arg(long, default_value = "binance", value_name = "VENUE")]
151    pub venue: String,
152
153    /// Maximum number of trades to fetch.
154    #[arg(long, default_value = "50", value_name = "LIMIT")]
155    pub limit: u32,
156
157    /// Output format.
158    #[arg(long, default_value = "text")]
159    pub format: OhlcFormat,
160}
161
162/// Output format for market data commands.
163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
164pub enum OhlcFormat {
165    /// Human-readable text table (default).
166    #[default]
167    Text,
168    /// JSON for programmatic consumption.
169    Json,
170}
171
172/// Run the market command.
173pub async fn run(
174    args: MarketCommands,
175    _config: &Config,
176    factory: &dyn ChainClientFactory,
177) -> Result<()> {
178    match args {
179        MarketCommands::Summary(summary_args) => run_summary(summary_args, factory).await,
180        MarketCommands::Ohlc(ohlc_args) => run_ohlc(ohlc_args).await,
181        MarketCommands::Trades(trades_args) => run_trades(trades_args).await,
182    }
183}
184
185/// Parse duration strings like "30s", "5m", "1h", "24h" into seconds.
186/// Extract base symbol from pair (e.g. "PUSD_USDT" -> "PUSD", "USDCUSDT" -> "USDC", "USDC" -> "USDC").
187fn base_symbol_from_pair(pair: &str) -> &str {
188    let p = pair.trim();
189    if let Some(i) = p.find("_USDT") {
190        return &p[..i];
191    }
192    if let Some(i) = p.find("_usdt") {
193        return &p[..i];
194    }
195    if let Some(i) = p.find("/USDT") {
196        return &p[..i];
197    }
198    if p.to_uppercase().ends_with("USDT") && p.len() > 4 {
199        return &p[..p.len() - 4];
200    }
201    p
202}
203
204fn parse_duration(s: &str) -> Result<u64> {
205    let s = s.trim();
206    if s.is_empty() {
207        return Err(ScopeError::Chain("Empty duration".to_string()));
208    }
209    let (num_str, unit) = s
210        .char_indices()
211        .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
212        .map(|(i, _)| (&s[..i], s[i..].trim()))
213        .unwrap_or((s, "s"));
214
215    let num: f64 = num_str
216        .parse()
217        .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
218
219    if num <= 0.0 {
220        return Err(ScopeError::Chain("Duration must be positive".to_string()));
221    }
222
223    let secs = match unit.to_lowercase().as_str() {
224        "s" | "sec" | "secs" | "second" | "seconds" => num,
225        "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
226        "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
227        "d" | "day" | "days" => num * 86400.0,
228        _ => {
229            return Err(ScopeError::Chain(format!(
230                "Unknown duration unit: {}",
231                unit
232            )));
233        }
234    };
235
236    Ok(secs as u64)
237}
238
239/// Builds markdown report content for a market summary.
240fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
241    let bid_dev = summary
242        .best_bid
243        .map(|b| (b - summary.peg_target) / summary.peg_target * 100.0);
244    let ask_dev = summary
245        .best_ask
246        .map(|a| (a - summary.peg_target) / summary.peg_target * 100.0);
247    let volume_row = summary
248        .volume_24h
249        .map(|v| format!("| Volume (24h) | {:.0} USDT |  \n", v))
250        .unwrap_or_default();
251    let exec_buy = summary
252        .execution_10k_buy
253        .as_ref()
254        .map(|e| {
255            if e.fillable {
256                format!("{:.2} bps", e.slippage_bps)
257            } else {
258                "insufficient".to_string()
259            }
260        })
261        .unwrap_or_else(|| "-".to_string());
262    let exec_sell = summary
263        .execution_10k_sell
264        .as_ref()
265        .map(|e| {
266            if e.fillable {
267                format!("{:.2} bps", e.slippage_bps)
268            } else {
269                "insufficient".to_string()
270            }
271        })
272        .unwrap_or_else(|| "-".to_string());
273    let mut md = format!(
274        "# Market Health Report: {}  \n\
275        **Venue:** {}  \n\
276        **Generated:** {}  \n\n\
277        ## Peg & Spread  \n\
278        | Metric | Value |  \n\
279        |--------|-------|  \n\
280        | Peg Target | {:.4} |  \n\
281        | Best Bid | {} |  \n\
282        | Best Ask | {} |  \n\
283        | Mid Price | {} |  \n\
284        | Spread | {} |  \n\
285        {}\
286        | 10k Buy Slippage | {} |  \n\
287        | 10k Sell Slippage | {} |  \n\
288        | Bid Depth | {:.0} |  \n\
289        | Ask Depth | {:.0} |  \n\
290        | Healthy | {} |  \n\n\
291        ## Health Checks  \n",
292        pair,
293        venue,
294        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
295        summary.peg_target,
296        summary
297            .best_bid
298            .map(|b| format!("{:.4} ({:+.3}%)", b, bid_dev.unwrap_or(0.0)))
299            .unwrap_or_else(|| "-".to_string()),
300        summary
301            .best_ask
302            .map(|a| format!("{:.4} ({:+.3}%)", a, ask_dev.unwrap_or(0.0)))
303            .unwrap_or_else(|| "-".to_string()),
304        summary
305            .mid_price
306            .map(|m| format!("{:.4}", m))
307            .unwrap_or_else(|| "-".to_string()),
308        summary
309            .spread
310            .map(|s| format!("{:.4}", s))
311            .unwrap_or_else(|| "-".to_string()),
312        volume_row,
313        exec_buy,
314        exec_sell,
315        summary.bid_depth,
316        summary.ask_depth,
317        if summary.healthy { "✓" } else { "✗" }
318    );
319    for check in &summary.checks {
320        let (icon, msg) = match check {
321            crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
322            crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
323        };
324        md.push_str(&format!("- {} {}\n", icon, msg));
325    }
326    md.push_str(&crate::display::report::report_footer());
327    md
328}
329
330/// Whether the venue string refers to a DEX venue (handled by DexScreener, not the registry).
331fn is_dex_venue(venue: &str) -> bool {
332    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
333}
334
335/// Resolve DEX venue name to a canonical chain name.
336fn dex_venue_to_chain(venue: &str) -> &str {
337    match venue.to_lowercase().as_str() {
338        "ethereum" | "eth" => "ethereum",
339        "solana" => "solana",
340        _ => "ethereum",
341    }
342}
343
344async fn fetch_book_and_volume(
345    args: &SummaryArgs,
346    factory: &dyn ChainClientFactory,
347) -> Result<(OrderBook, Option<f64>)> {
348    let base = base_symbol_from_pair(&args.pair).to_string();
349
350    if is_dex_venue(&args.venue) {
351        // DEX path: synthesize from DexScreener analytics
352        let chain = dex_venue_to_chain(&args.venue);
353        let analytics =
354            crawl::fetch_analytics_for_input(&base, chain, Period::Hour24, 10, factory, None)
355                .await?;
356        if analytics.dex_pairs.is_empty() {
357            return Err(ScopeError::Chain(format!(
358                "No DEX pairs found for {} on {}",
359                base, chain
360            )));
361        }
362        let best_pair = analytics
363            .dex_pairs
364            .iter()
365            .max_by(|a, b| {
366                a.liquidity_usd
367                    .partial_cmp(&b.liquidity_usd)
368                    .unwrap_or(std::cmp::Ordering::Equal)
369            })
370            .unwrap();
371        let book = order_book_from_analytics(chain, best_pair, &analytics.token.symbol);
372        let volume = Some(best_pair.volume_24h);
373        Ok((book, volume))
374    } else {
375        // CEX path: use VenueRegistry + ExchangeClient
376        let registry = VenueRegistry::load()?;
377        let exchange = registry.create_exchange_client(&args.venue)?;
378        let pair = exchange.format_pair(&base);
379        let book = exchange.fetch_order_book(&pair).await?;
380
381        // Get volume from ticker if available
382        let volume = if exchange.has_ticker() {
383            exchange
384                .fetch_ticker(&pair)
385                .await
386                .ok()
387                .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
388        } else {
389            None
390        };
391        Ok((book, volume))
392    }
393}
394
395async fn run_summary_once(
396    args: &SummaryArgs,
397    factory: &dyn ChainClientFactory,
398    thresholds: &HealthThresholds,
399    run_num: Option<u64>,
400) -> Result<MarketSummary> {
401    if let Some(n) = run_num {
402        let ts = chrono::Utc::now().format("%H:%M:%S");
403        eprintln!("  --- Run #{} at {} ---\n", n, ts);
404    }
405
406    let (book, volume_24h) = fetch_book_and_volume(args, factory).await?;
407    let summary = MarketSummary::from_order_book(&book, args.peg, thresholds, volume_24h);
408
409    let venue_label = args.venue.clone();
410
411    match args.format {
412        SummaryFormat::Text => {
413            print!("{}", summary.format_text(Some(&venue_label)));
414        }
415        SummaryFormat::Json => {
416            let json = serde_json::json!({
417                "run": run_num,
418                "venue": venue_label,
419                "pair": summary.pair,
420                "peg_target": summary.peg_target,
421                "best_bid": summary.best_bid,
422                "best_ask": summary.best_ask,
423                "mid_price": summary.mid_price,
424                "spread": summary.spread,
425                "volume_24h": summary.volume_24h,
426                "execution_10k_buy": summary.execution_10k_buy.as_ref().map(|e| serde_json::json!({
427                    "fillable": e.fillable,
428                    "slippage_bps": e.slippage_bps
429                })),
430                "execution_10k_sell": summary.execution_10k_sell.as_ref().map(|e| serde_json::json!({
431                    "fillable": e.fillable,
432                    "slippage_bps": e.slippage_bps
433                })),
434                "ask_depth": summary.ask_depth,
435                "bid_depth": summary.bid_depth,
436                "ask_levels": summary.asks.len(),
437                "bid_levels": summary.bids.len(),
438                "healthy": summary.healthy,
439                "checks": summary.checks.iter().map(|c| match c {
440                    crate::market::HealthCheck::Pass(m) => serde_json::json!({"status": "pass", "message": m}),
441                    crate::market::HealthCheck::Fail(m) => serde_json::json!({"status": "fail", "message": m}),
442                }).collect::<Vec<_>>(),
443            });
444            println!("{}", serde_json::to_string_pretty(&json)?);
445        }
446    }
447
448    Ok(summary)
449}
450
451async fn run_summary(args: SummaryArgs, factory: &dyn ChainClientFactory) -> Result<()> {
452    let thresholds = HealthThresholds {
453        peg_target: args.peg,
454        peg_range: args.peg_range,
455        min_levels: args.min_levels,
456        min_depth: args.min_depth,
457        min_bid_ask_ratio: args.min_bid_ask_ratio,
458        max_bid_ask_ratio: args.max_bid_ask_ratio,
459    };
460
461    let repeat_mode = args.every.is_some() || args.duration.is_some();
462
463    if !repeat_mode {
464        let summary = run_summary_once(&args, factory, &thresholds, None).await?;
465        if let Some(ref report_path) = args.report {
466            let venue_label = args.venue.clone();
467            let md = market_summary_to_markdown(&summary, &venue_label, &args.pair);
468            std::fs::write(report_path, md)?;
469            eprintln!("\nReport saved to: {}", report_path.display());
470        }
471        return Ok(());
472    }
473
474    let every_secs = args
475        .every
476        .as_ref()
477        .map(|s| parse_duration(s))
478        .transpose()?
479        .unwrap_or(DEFAULT_EVERY_SECS);
480
481    let duration_secs = args
482        .duration
483        .as_ref()
484        .map(|s| parse_duration(s))
485        .transpose()?
486        .unwrap_or(DEFAULT_DURATION_SECS);
487
488    if every_secs == 0 {
489        return Err(ScopeError::Chain("Interval must be positive".to_string()));
490    }
491
492    let every = Duration::from_secs(every_secs);
493    let start = std::time::Instant::now();
494    let duration = Duration::from_secs(duration_secs);
495
496    eprintln!(
497        "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
498        every_secs, duration_secs
499    );
500
501    let mut run_num: u64 = 1;
502    #[allow(unused_assignments)]
503    let mut last_summary: Option<MarketSummary> = None;
504
505    // Initialize CSV if requested
506    if let Some(ref csv_path) = args.csv {
507        let header =
508            "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy\n";
509        std::fs::write(csv_path, header)?;
510    }
511
512    loop {
513        let summary = run_summary_once(&args, factory, &thresholds, Some(run_num)).await?;
514        last_summary = Some(summary.clone());
515
516        // Append CSV row
517        if let Some(ref csv_path) = args.csv {
518            let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
519            let bid = summary
520                .best_bid
521                .map(|v| v.to_string())
522                .unwrap_or_else(|| "-".to_string());
523            let ask = summary
524                .best_ask
525                .map(|v| v.to_string())
526                .unwrap_or_else(|| "-".to_string());
527            let mid = summary
528                .mid_price
529                .map(|v| v.to_string())
530                .unwrap_or_else(|| "-".to_string());
531            let spread = summary
532                .spread
533                .map(|v| v.to_string())
534                .unwrap_or_else(|| "-".to_string());
535            let row = format!(
536                "{},{},{},{},{},{},{},{},{}\n",
537                ts,
538                run_num,
539                bid,
540                ask,
541                mid,
542                spread,
543                summary.bid_depth,
544                summary.ask_depth,
545                summary.healthy
546            );
547            let mut f = std::fs::OpenOptions::new().append(true).open(csv_path)?;
548            use std::io::Write;
549            f.write_all(row.as_bytes())?;
550        }
551
552        if start.elapsed() >= duration {
553            eprintln!("\nCompleted {} run(s) over {}s.", run_num, duration_secs);
554            break;
555        }
556
557        run_num += 1;
558
559        let remaining = duration.saturating_sub(start.elapsed());
560        let sleep_duration = if remaining < every { remaining } else { every };
561        tokio::time::sleep(sleep_duration).await;
562    }
563
564    // Save final report if requested (last_summary always set when loop runs)
565    if let (Some(ref report_path), Some(summary)) = (args.report, last_summary.as_ref()) {
566        let venue_label = args.venue.clone();
567        let md = market_summary_to_markdown(summary, &venue_label, &args.pair);
568        std::fs::write(report_path, md)?;
569        eprintln!("Report saved to: {}", report_path.display());
570    }
571    if let Some(ref csv_path) = args.csv {
572        eprintln!("Time-series CSV saved to: {}", csv_path.display());
573    }
574
575    Ok(())
576}
577
578// =============================================================================
579// OHLC Command
580// =============================================================================
581
582/// Execute the `scope market ohlc` command.
583async fn run_ohlc(args: OhlcArgs) -> Result<()> {
584    let registry = VenueRegistry::load()?;
585    let descriptor = registry.get(&args.venue).ok_or_else(|| {
586        ScopeError::NotFound(format!(
587            "Venue '{}' not found. Use `scope venues list` to see available venues.",
588            args.venue
589        ))
590    })?;
591
592    let client = crate::market::ExchangeClient::from_descriptor(descriptor);
593    let pair = client.format_pair(base_symbol_from_pair(&args.pair));
594
595    let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
596
597    match args.format {
598        OhlcFormat::Json => {
599            let json_candles: Vec<serde_json::Value> = candles
600                .iter()
601                .map(|c| {
602                    serde_json::json!({
603                        "open_time": c.open_time,
604                        "open": c.open,
605                        "high": c.high,
606                        "low": c.low,
607                        "close": c.close,
608                        "volume": c.volume,
609                        "close_time": c.close_time,
610                    })
611                })
612                .collect();
613            println!("{}", serde_json::to_string_pretty(&json_candles).unwrap());
614        }
615        OhlcFormat::Text => {
616            println!();
617            println!(
618                "OHLC — {} ({}) interval={} limit={}",
619                pair, args.venue, args.interval, args.limit
620            );
621            println!("──────────────────────────────────────────────────────────");
622            println!(
623                "  {:>19}  {:>12}  {:>12}  {:>12}  {:>12}  {:>14}",
624                "Open Time", "Open", "High", "Low", "Close", "Volume"
625            );
626            println!("──────────────────────────────────────────────────────────");
627            for c in &candles {
628                let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
629                    .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
630                    .unwrap_or_else(|| format!("{}", c.open_time));
631                println!(
632                    "  {:>19}  {:>12.6}  {:>12.6}  {:>12.6}  {:>12.6}  {:>14.2}",
633                    dt, c.open, c.high, c.low, c.close, c.volume
634                );
635            }
636            println!();
637            println!("  {} candles returned", candles.len());
638            println!();
639        }
640    }
641    Ok(())
642}
643
644// =============================================================================
645// Trades Command
646// =============================================================================
647
648/// Execute the `scope market trades` command.
649async fn run_trades(args: TradesArgs) -> Result<()> {
650    let registry = VenueRegistry::load()?;
651    let descriptor = registry.get(&args.venue).ok_or_else(|| {
652        ScopeError::NotFound(format!(
653            "Venue '{}' not found. Use `scope venues list` to see available venues.",
654            args.venue
655        ))
656    })?;
657
658    let client = crate::market::ExchangeClient::from_descriptor(descriptor);
659    let pair = client.format_pair(base_symbol_from_pair(&args.pair));
660
661    let trades = client.fetch_recent_trades(&pair, args.limit).await?;
662
663    match args.format {
664        OhlcFormat::Json => {
665            let json_trades: Vec<serde_json::Value> = trades
666                .iter()
667                .map(|t| {
668                    serde_json::json!({
669                        "price": t.price,
670                        "quantity": t.quantity,
671                        "quote_quantity": t.quote_quantity,
672                        "timestamp_ms": t.timestamp_ms,
673                        "side": format!("{:?}", t.side),
674                    })
675                })
676                .collect();
677            println!("{}", serde_json::to_string_pretty(&json_trades).unwrap());
678        }
679        OhlcFormat::Text => {
680            println!();
681            println!("Recent Trades — {} ({})", pair, args.venue);
682            println!("──────────────────────────────────────");
683            println!(
684                "  {:>10}  {:>5}  {:>12}  {:>12}",
685                "Time", "Side", "Price", "Qty"
686            );
687            println!("──────────────────────────────────────");
688            for t in &trades {
689                let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
690                    .map(|d| d.format("%H:%M:%S").to_string())
691                    .unwrap_or_else(|| "?".to_string());
692                let side = match t.side {
693                    crate::market::TradeSide::Buy => "BUY",
694                    crate::market::TradeSide::Sell => "SELL",
695                };
696                println!(
697                    "  {:>10}  {:>5}  {:>12.6}  {:>12.2}",
698                    time, side, t.price, t.quantity
699                );
700            }
701            println!();
702            println!("  {} trades returned", trades.len());
703            println!();
704        }
705    }
706    Ok(())
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use crate::chains::DefaultClientFactory;
713
714    /// Helper to create a mock venue YAML pointing at the given mock server URL.
715    /// Writes a temporary venue descriptor to the user venues directory so the
716    /// registry picks it up. Returns the venue id.
717    #[allow(dead_code)]
718    fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
719        let venue_id = format!("test_mock_{}", std::process::id());
720        let yaml = format!(
721            r#"
722id: {venue_id}
723name: Test Mock Venue
724base_url: {server_url}
725timeout_secs: 5
726symbol:
727  template: "{{base}}_{{quote}}"
728  default_quote: USDT
729capabilities:
730  order_book:
731    path: /api/v1/depth
732    params:
733      symbol: "{{pair}}"
734    response:
735      asks_key: asks
736      bids_key: bids
737      level_format: positional
738  ticker:
739    path: /api/v1/ticker
740    params:
741      symbol: "{{pair}}"
742    response:
743      last_price: last
744      volume_24h: vol
745"#
746        );
747        let dir = tempfile::tempdir().unwrap();
748        let file_path = dir.path().join(format!("{}.yaml", venue_id));
749        std::fs::write(&file_path, yaml).unwrap();
750        // We can't easily inject into the registry, so instead
751        // create a ConfigurableExchangeClient directly in tests.
752        (venue_id, dir)
753    }
754
755    #[tokio::test]
756    async fn test_run_summary_with_mock_orderbook() {
757        // This test uses the DEX path since mock HTTP with the registry is complex.
758        // Tested in integration tests and exchange module unit tests instead.
759        // Test duration parsing and summary formatting here.
760        let args = SummaryArgs {
761            pair: "USDC".to_string(),
762            venue: "eth".to_string(),
763            chain: "ethereum".to_string(),
764            peg: 1.0,
765            min_levels: 1,
766            min_depth: 50.0,
767            peg_range: 0.01,
768            min_bid_ask_ratio: 0.1,
769            max_bid_ask_ratio: 10.0,
770            format: SummaryFormat::Text,
771            every: None,
772            duration: None,
773            report: None,
774            csv: None,
775        };
776
777        let factory = DefaultClientFactory {
778            chains_config: Default::default(),
779        };
780        // DEX path: will hit real DexScreener API, may fail in offline environments
781        let _result = run_summary(args, &factory).await;
782        // We don't assert success because it depends on network, just confirm no panic
783    }
784
785    #[tokio::test]
786    async fn test_run_summary_json_format() {
787        let args = SummaryArgs {
788            pair: "USDC".to_string(),
789            venue: "eth".to_string(),
790            chain: "ethereum".to_string(),
791            peg: 1.0,
792            min_levels: 1,
793            min_depth: 50.0,
794            peg_range: 0.01,
795            min_bid_ask_ratio: 0.1,
796            max_bid_ask_ratio: 10.0,
797            format: SummaryFormat::Json,
798            every: None,
799            duration: None,
800            report: None,
801            csv: None,
802        };
803
804        let factory = DefaultClientFactory {
805            chains_config: Default::default(),
806        };
807        let _result = run_summary(args, &factory).await;
808    }
809
810    #[test]
811    fn test_parse_duration_seconds() {
812        assert_eq!(parse_duration("30s").unwrap(), 30);
813        assert_eq!(parse_duration("1").unwrap(), 1);
814        assert_eq!(parse_duration("60sec").unwrap(), 60);
815    }
816
817    #[test]
818    fn test_parse_duration_minutes() {
819        assert_eq!(parse_duration("5m").unwrap(), 300);
820        assert_eq!(parse_duration("1min").unwrap(), 60);
821        assert_eq!(parse_duration("2.5m").unwrap(), 150);
822    }
823
824    #[test]
825    fn test_parse_duration_hours() {
826        assert_eq!(parse_duration("1h").unwrap(), 3600);
827        assert_eq!(parse_duration("24h").unwrap(), 86400);
828    }
829
830    #[test]
831    fn test_parse_duration_invalid() {
832        assert!(parse_duration("").is_err());
833        assert!(parse_duration("abc").is_err());
834        assert!(parse_duration("30x").is_err());
835    }
836
837    #[test]
838    fn test_parse_duration_non_positive() {
839        assert!(parse_duration("0").is_err());
840        assert!(parse_duration("-5s").is_err());
841    }
842
843    // ====================================================================
844    // base_symbol_from_pair tests
845    // ====================================================================
846
847    #[test]
848    fn test_base_symbol_from_pair_underscore() {
849        assert_eq!(base_symbol_from_pair("PUSD_USDT"), "PUSD");
850        assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
851    }
852
853    #[test]
854    fn test_base_symbol_from_pair_lowercase_underscore() {
855        assert_eq!(base_symbol_from_pair("pusd_usdt"), "pusd");
856    }
857
858    #[test]
859    fn test_base_symbol_from_pair_slash() {
860        assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
861    }
862
863    #[test]
864    fn test_base_symbol_from_pair_concat() {
865        assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
866        assert_eq!(base_symbol_from_pair("PUSDUSDT"), "PUSD");
867    }
868
869    #[test]
870    fn test_base_symbol_from_pair_plain() {
871        assert_eq!(base_symbol_from_pair("USDC"), "USDC");
872        assert_eq!(base_symbol_from_pair("ETH"), "ETH");
873    }
874
875    #[test]
876    fn test_base_symbol_from_pair_whitespace() {
877        assert_eq!(base_symbol_from_pair("  PUSD_USDT  "), "PUSD");
878    }
879
880    // ====================================================================
881    // market_summary_to_markdown tests
882    // ====================================================================
883
884    #[test]
885    fn test_market_summary_to_markdown_basic() {
886        use crate::market::{HealthCheck, MarketSummary};
887        let summary = MarketSummary {
888            pair: "USDCUSDT".to_string(),
889            peg_target: 1.0,
890            best_bid: Some(0.9999),
891            best_ask: Some(1.0001),
892            mid_price: Some(1.0000),
893            spread: Some(0.0002),
894            volume_24h: Some(1_000_000.0),
895            bid_depth: 50_000.0,
896            ask_depth: 50_000.0,
897            bid_outliers: 0,
898            ask_outliers: 0,
899            healthy: true,
900            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
901            execution_10k_buy: None,
902            execution_10k_sell: None,
903            asks: vec![],
904            bids: vec![],
905        };
906        let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
907        assert!(md.contains("Market Health Report"));
908        assert!(md.contains("USDCUSDT"));
909        assert!(md.contains("Binance"));
910        assert!(md.contains("Peg Target"));
911        assert!(md.contains("1.0000"));
912        assert!(md.contains("Healthy"));
913    }
914
915    #[test]
916    fn test_market_summary_to_markdown_no_prices() {
917        use crate::market::{HealthCheck, MarketSummary};
918        let summary = MarketSummary {
919            pair: "TESTUSDT".to_string(),
920            peg_target: 1.0,
921            best_bid: None,
922            best_ask: None,
923            mid_price: None,
924            spread: None,
925            volume_24h: None,
926            bid_depth: 0.0,
927            ask_depth: 0.0,
928            bid_outliers: 0,
929            ask_outliers: 0,
930            healthy: false,
931            checks: vec![HealthCheck::Fail("No data".to_string())],
932            execution_10k_buy: None,
933            execution_10k_sell: None,
934            asks: vec![],
935            bids: vec![],
936        };
937        let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
938        assert!(md.contains("Market Health Report"));
939        assert!(md.contains("-")); // missing data shown as "-"
940    }
941
942    // ====================================================================
943    // parse_duration — additional edge cases
944    // ====================================================================
945
946    #[test]
947    fn test_parse_duration_days() {
948        assert_eq!(parse_duration("1d").unwrap(), 86400);
949        assert_eq!(parse_duration("7d").unwrap(), 604800);
950        assert_eq!(parse_duration("1day").unwrap(), 86400);
951        assert_eq!(parse_duration("2days").unwrap(), 172800);
952    }
953
954    #[test]
955    fn test_parse_duration_long_names() {
956        assert_eq!(parse_duration("30seconds").unwrap(), 30);
957        assert_eq!(parse_duration("5minutes").unwrap(), 300);
958        assert_eq!(parse_duration("2hours").unwrap(), 7200);
959    }
960
961    #[test]
962    fn test_parse_duration_fractional() {
963        assert_eq!(parse_duration("0.5h").unwrap(), 1800);
964        assert_eq!(parse_duration("1.5m").unwrap(), 90);
965    }
966
967    // ====================================================================
968    // SummaryFormat tests
969    // ====================================================================
970
971    #[test]
972    fn test_summary_format_default() {
973        let fmt = SummaryFormat::default();
974        assert!(matches!(fmt, SummaryFormat::Text));
975    }
976
977    #[test]
978    fn test_summary_format_debug() {
979        let text = format!("{:?}", SummaryFormat::Text);
980        assert_eq!(text, "Text");
981        let json = format!("{:?}", SummaryFormat::Json);
982        assert_eq!(json, "Json");
983    }
984
985    // ====================================================================
986    // MarketCommands parsing tests
987    // ====================================================================
988
989    #[test]
990    fn test_ohlc_args_deserialization() {
991        use crate::cli::{Cli, Commands};
992        use clap::Parser;
993        let cli = Cli::try_parse_from([
994            "scope",
995            "market",
996            "ohlc",
997            "USDC",
998            "--venue",
999            "binance",
1000            "--interval",
1001            "1h",
1002            "--limit",
1003            "50",
1004        ])
1005        .unwrap();
1006        if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1007            assert_eq!(args.pair, "USDC");
1008            assert_eq!(args.venue, "binance");
1009            assert_eq!(args.interval, "1h");
1010            assert_eq!(args.limit, 50);
1011        } else {
1012            panic!("Expected Market Ohlc command");
1013        }
1014    }
1015
1016    #[test]
1017    fn test_trades_args_deserialization() {
1018        use crate::cli::{Cli, Commands};
1019        use clap::Parser;
1020        let cli = Cli::try_parse_from([
1021            "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1022        ])
1023        .unwrap();
1024        if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1025            assert_eq!(args.pair, "BTC");
1026            assert_eq!(args.venue, "mexc");
1027            assert_eq!(args.limit, 100);
1028        } else {
1029            panic!("Expected Market Trades command");
1030        }
1031    }
1032
1033    #[test]
1034    fn test_base_symbol_from_pair_various_inputs() {
1035        // Additional coverage for edge cases
1036        assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1037        assert_eq!(base_symbol_from_pair("BTCUSDT"), "BTC");
1038        assert_eq!(base_symbol_from_pair("ETH/USDT"), "ETH");
1039        assert_eq!(base_symbol_from_pair("PUSD_USDT"), "PUSD");
1040        assert_eq!(base_symbol_from_pair("X"), "X"); // short symbol, no USDT suffix
1041        assert_eq!(base_symbol_from_pair(""), "");
1042    }
1043
1044    #[test]
1045    fn test_summary_args_debug() {
1046        let args = SummaryArgs {
1047            pair: "USDC".to_string(),
1048            venue: "binance".to_string(),
1049            chain: "ethereum".to_string(),
1050            peg: 1.0,
1051            min_levels: 6,
1052            min_depth: 3000.0,
1053            peg_range: 0.001,
1054            min_bid_ask_ratio: 0.2,
1055            max_bid_ask_ratio: 5.0,
1056            format: SummaryFormat::Text,
1057            every: None,
1058            duration: None,
1059            report: None,
1060            csv: None,
1061        };
1062        let debug = format!("{:?}", args);
1063        assert!(debug.contains("SummaryArgs"));
1064        assert!(debug.contains("USDC"));
1065    }
1066
1067    #[test]
1068    fn test_default_constants() {
1069        assert_eq!(DEFAULT_EVERY_SECS, 60);
1070        assert_eq!(DEFAULT_DURATION_SECS, 3600);
1071    }
1072
1073    #[test]
1074    fn test_market_summary_to_markdown_with_execution_estimates() {
1075        use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1076        let summary = MarketSummary {
1077            pair: "TESTUSDT".to_string(),
1078            peg_target: 1.0,
1079            best_bid: Some(0.9999),
1080            best_ask: Some(1.0001),
1081            mid_price: Some(1.0000),
1082            spread: Some(0.0002),
1083            volume_24h: Some(1_000_000.0),
1084            bid_depth: 50_000.0,
1085            ask_depth: 50_000.0,
1086            bid_outliers: 0,
1087            ask_outliers: 0,
1088            healthy: true,
1089            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1090            execution_10k_buy: Some(ExecutionEstimate {
1091                notional_usdt: 10_000.0,
1092                side: ExecutionSide::Buy,
1093                vwap: 1.0001,
1094                slippage_bps: 1.5,
1095                fillable: true,
1096            }),
1097            execution_10k_sell: Some(ExecutionEstimate {
1098                notional_usdt: 10_000.0,
1099                side: ExecutionSide::Sell,
1100                vwap: 0.0,
1101                slippage_bps: 0.0,
1102                fillable: false,
1103            }),
1104            asks: vec![],
1105            bids: vec![],
1106        };
1107        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1108        assert!(md.contains("Market Health Report"));
1109        assert!(md.contains("TESTUSDT"));
1110        assert!(md.contains("TestVenue"));
1111        // Check for fillable buy slippage (should show "1.50 bps")
1112        assert!(md.contains("1.50 bps"));
1113        // Check for unfillable sell (should show "insufficient")
1114        assert!(md.contains("insufficient"));
1115    }
1116
1117    #[tokio::test]
1118    async fn test_run_with_summary_command() {
1119        // Test the run() dispatcher with a DEX venue (doesn't require mock HTTP)
1120        let args = MarketCommands::Summary(SummaryArgs {
1121            pair: "USDC".to_string(),
1122            venue: "eth".to_string(),
1123            chain: "ethereum".to_string(),
1124            peg: 1.0,
1125            min_levels: 1,
1126            min_depth: 50.0,
1127            peg_range: 0.01,
1128            min_bid_ask_ratio: 0.1,
1129            max_bid_ask_ratio: 10.0,
1130            format: SummaryFormat::Text,
1131            every: None,
1132            duration: None,
1133            report: None,
1134            csv: None,
1135        });
1136
1137        let factory = DefaultClientFactory {
1138            chains_config: Default::default(),
1139        };
1140        let config = Config::default();
1141        let _result = run(args, &config, &factory).await;
1142        // Don't assert success - depends on network
1143    }
1144
1145    #[test]
1146    fn test_is_dex_venue() {
1147        assert!(is_dex_venue("eth"));
1148        assert!(is_dex_venue("ethereum"));
1149        assert!(is_dex_venue("Ethereum"));
1150        assert!(is_dex_venue("solana"));
1151        assert!(is_dex_venue("Solana"));
1152        assert!(!is_dex_venue("binance"));
1153        assert!(!is_dex_venue("okx"));
1154        assert!(!is_dex_venue("mexc"));
1155    }
1156
1157    #[test]
1158    fn test_dex_venue_to_chain() {
1159        assert_eq!(dex_venue_to_chain("eth"), "ethereum");
1160        assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
1161        assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
1162        assert_eq!(dex_venue_to_chain("solana"), "solana");
1163    }
1164
1165    #[test]
1166    fn test_venue_registry_loaded_in_cex_path() {
1167        // Verify the registry loads and can create an exchange client for any built-in venue
1168        let registry = VenueRegistry::load().unwrap();
1169        assert!(registry.contains("binance"));
1170        let client = registry.create_exchange_client("binance");
1171        assert!(client.is_ok());
1172    }
1173
1174    #[test]
1175    fn test_venue_registry_error_for_unknown() {
1176        let registry = VenueRegistry::load().unwrap();
1177        let result = registry.create_exchange_client("kracken");
1178        assert!(result.is_err());
1179        let err = result.unwrap_err().to_string();
1180        assert!(err.contains("Unknown venue"));
1181        assert!(err.contains("Did you mean")); // should suggest kraken (distance 1)
1182    }
1183
1184    #[tokio::test]
1185    async fn test_run_summary_json_format_with_mock() {
1186        let args = SummaryArgs {
1187            pair: "USDC".to_string(),
1188            venue: "eth".to_string(),
1189            chain: "ethereum".to_string(),
1190            peg: 1.0,
1191            min_levels: 1,
1192            min_depth: 50.0,
1193            peg_range: 0.01,
1194            min_bid_ask_ratio: 0.1,
1195            max_bid_ask_ratio: 10.0,
1196            format: SummaryFormat::Json,
1197            every: None,
1198            duration: None,
1199            report: None,
1200            csv: None,
1201        };
1202
1203        let factory = DefaultClientFactory {
1204            chains_config: Default::default(),
1205        };
1206        let _result = run_summary(args, &factory).await;
1207    }
1208
1209    // ====================================================================
1210    // OHLC command tests
1211    // ====================================================================
1212
1213    #[test]
1214    fn test_ohlc_format_default() {
1215        let fmt: OhlcFormat = Default::default();
1216        assert_eq!(fmt, OhlcFormat::Text);
1217    }
1218
1219    #[test]
1220    fn test_ohlc_format_display() {
1221        // ValueEnum-derived parsing
1222        assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1223        assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1224    }
1225
1226    #[test]
1227    fn test_ohlc_args_default_values() {
1228        // Verify we can construct OhlcArgs with defaults
1229        let args = OhlcArgs {
1230            pair: "BTC".to_string(),
1231            venue: "binance".to_string(),
1232            interval: "1h".to_string(),
1233            limit: 100,
1234            format: OhlcFormat::Text,
1235        };
1236        assert_eq!(args.pair, "BTC");
1237        assert_eq!(args.venue, "binance");
1238        assert_eq!(args.interval, "1h");
1239        assert_eq!(args.limit, 100);
1240    }
1241
1242    #[test]
1243    fn test_trades_args_construction() {
1244        let args = TradesArgs {
1245            pair: "ETH".to_string(),
1246            venue: "okx".to_string(),
1247            limit: 50,
1248            format: OhlcFormat::Json,
1249        };
1250        assert_eq!(args.pair, "ETH");
1251        assert_eq!(args.venue, "okx");
1252        assert_eq!(args.limit, 50);
1253    }
1254
1255    #[tokio::test]
1256    async fn test_run_ohlc_unknown_venue() {
1257        let args = OhlcArgs {
1258            pair: "BTC".to_string(),
1259            venue: "nonexistent_venue".to_string(),
1260            interval: "1h".to_string(),
1261            limit: 10,
1262            format: OhlcFormat::Text,
1263        };
1264        let result = run_ohlc(args).await;
1265        assert!(result.is_err());
1266        let err = result.unwrap_err().to_string();
1267        assert!(
1268            err.contains("not found"),
1269            "expected 'not found' error, got: {}",
1270            err
1271        );
1272    }
1273
1274    #[tokio::test]
1275    async fn test_run_trades_unknown_venue() {
1276        let args = TradesArgs {
1277            pair: "BTC".to_string(),
1278            venue: "nonexistent_venue".to_string(),
1279            limit: 10,
1280            format: OhlcFormat::Text,
1281        };
1282        let result = run_trades(args).await;
1283        assert!(result.is_err());
1284        let err = result.unwrap_err().to_string();
1285        assert!(
1286            err.contains("not found"),
1287            "expected 'not found' error, got: {}",
1288            err
1289        );
1290    }
1291
1292    #[tokio::test]
1293    async fn test_run_dispatches_ohlc() {
1294        let cmd = MarketCommands::Ohlc(OhlcArgs {
1295            pair: "BTC".to_string(),
1296            venue: "nonexistent_test_venue".to_string(),
1297            interval: "1h".to_string(),
1298            limit: 5,
1299            format: OhlcFormat::Text,
1300        });
1301        let factory = DefaultClientFactory {
1302            chains_config: Default::default(),
1303        };
1304        let config = Config::default();
1305        let result = run(cmd, &config, &factory).await;
1306        // Should error with venue not found
1307        assert!(result.is_err());
1308    }
1309
1310    #[tokio::test]
1311    async fn test_run_dispatches_trades() {
1312        let cmd = MarketCommands::Trades(TradesArgs {
1313            pair: "ETH".to_string(),
1314            venue: "nonexistent_test_venue".to_string(),
1315            limit: 5,
1316            format: OhlcFormat::Json,
1317        });
1318        let factory = DefaultClientFactory {
1319            chains_config: Default::default(),
1320        };
1321        let config = Config::default();
1322        let result = run(cmd, &config, &factory).await;
1323        assert!(result.is_err());
1324    }
1325
1326    #[tokio::test]
1327    async fn test_run_ohlc_text_format_with_real_venue() {
1328        // Uses a real venue with a real API call. May succeed or fail depending
1329        // on network availability. Exercises venue resolution and client creation.
1330        let args = OhlcArgs {
1331            pair: "BTC".to_string(),
1332            venue: "binance".to_string(),
1333            interval: "1h".to_string(),
1334            limit: 3,
1335            format: OhlcFormat::Text,
1336        };
1337        let _result = run_ohlc(args).await;
1338        // Don't assert success — depends on network
1339    }
1340
1341    #[tokio::test]
1342    async fn test_run_ohlc_json_format_with_real_venue() {
1343        let args = OhlcArgs {
1344            pair: "ETH".to_string(),
1345            venue: "binance".to_string(),
1346            interval: "15m".to_string(),
1347            limit: 2,
1348            format: OhlcFormat::Json,
1349        };
1350        let _result = run_ohlc(args).await;
1351    }
1352
1353    #[tokio::test]
1354    async fn test_run_trades_text_format_with_real_venue() {
1355        let args = TradesArgs {
1356            pair: "BTC".to_string(),
1357            venue: "binance".to_string(),
1358            limit: 5,
1359            format: OhlcFormat::Text,
1360        };
1361        let _result = run_trades(args).await;
1362    }
1363
1364    #[tokio::test]
1365    async fn test_run_trades_json_format_with_real_venue() {
1366        let args = TradesArgs {
1367            pair: "ETH".to_string(),
1368            venue: "binance".to_string(),
1369            limit: 3,
1370            format: OhlcFormat::Json,
1371        };
1372        let _result = run_trades(args).await;
1373    }
1374
1375    #[tokio::test]
1376    async fn test_run_ohlc_multiple_venues() {
1377        // Exercise venue resolution for several built-in venues
1378        for venue in &["mexc", "okx", "bybit"] {
1379            let args = OhlcArgs {
1380                pair: "BTC".to_string(),
1381                venue: venue.to_string(),
1382                interval: "1h".to_string(),
1383                limit: 2,
1384                format: OhlcFormat::Json,
1385            };
1386            let _result = run_ohlc(args).await;
1387        }
1388    }
1389
1390    #[tokio::test]
1391    async fn test_run_trades_multiple_venues() {
1392        for venue in &["mexc", "okx", "bybit"] {
1393            let args = TradesArgs {
1394                pair: "BTC".to_string(),
1395                venue: venue.to_string(),
1396                limit: 3,
1397                format: OhlcFormat::Text,
1398            };
1399            let _result = run_trades(args).await;
1400        }
1401    }
1402}