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::display::terminal as t;
12use crate::error::{Result, ScopeError};
13use crate::market::{
14    HealthThresholds, MarketSummary, OrderBook, VenueRegistry, order_book_from_analytics,
15};
16use clap::{Args, Subcommand};
17use std::time::Duration;
18
19/// Default interval between summary runs when in repeat mode (60 seconds).
20pub const DEFAULT_EVERY_SECS: u64 = 60;
21
22/// Default total duration when in repeat mode (1 hour).
23pub const DEFAULT_DURATION_SECS: u64 = 3600;
24
25/// Market subcommands.
26#[derive(Debug, Subcommand)]
27pub enum MarketCommands {
28    /// One-screen peg and order book health summary.
29    ///
30    /// Displays best bid/ask, mid price, spread, order book levels,
31    /// and configurable health checks (peg safety, bid/ask ratio, depth thresholds).
32    ///
33    /// Use --every and --duration to run repeatedly (e.g., every 30s for 1 hour).
34    Summary(SummaryArgs),
35
36    /// Fetch OHLC/candlestick (kline) data from a CEX venue.
37    Ohlc(OhlcArgs),
38
39    /// Fetch recent trades from a CEX venue.
40    Trades(TradesArgs),
41}
42
43/// Arguments for `scope market summary`.
44///
45/// Default thresholds (min_levels, min_depth, peg_range) originated from
46/// stablecoin market-making defaults and are tunable for other markets.
47#[derive(Debug, Args)]
48#[command(
49    after_help = "\x1b[1mExamples:\x1b[0m
50  scope market summary DAI --venue binance
51  scope market summary @dai-token --venue binance         \x1b[2m# address book shortcut\x1b[0m
52  scope market summary USDC --venue binance --format json
53  scope market summary DAI --venue binance --every 30s --duration 1h
54  scope market summary DAI --venue binance --report health.md --csv peg.csv",
55    after_long_help = "\x1b[1mExamples:\x1b[0m
56
57  \x1b[1m$ scope market summary DAI --venue binance\x1b[0m
58
59  +-- DAI/USDT (binance) ----------------------------+
60  |                                                     |
61  |-- Metrics                                           |
62  |  Best Bid           0.9999  (-0.010%)               |
63  |  Best Ask           1.0001  (+0.010%)               |
64  |  Mid Price          1.0000  (+0.000%)               |
65  |  Spread             0.0002  (0.020%)                |
66  |  Volume (24h)       125000 USDT                     |
67  |                                                     |
68  |-- Health Checks                                     |
69  |  + No sells below peg                               |
70  |  + Bid/Ask ratio: 0.93x                             |
71  |  + Bid levels: 8 >= 6 minimum                       |
72  |  + Bid depth: 42000 USDT >= 3000 USDT minimum       |
73  |                                                     |
74  |  HEALTHY                                            |
75  +-----------------------------------------------------+
76
77  \x1b[1m$ scope market summary DAI --venue binance --every 30s --duration 1h\x1b[0m
78
79  Monitoring DAI/USDT (binance) every 30s for 1h...
80  [2026-02-16 10:00:00] Mid=1.0000 Spread=0.020% Depth=42K/45K HEALTHY
81  [2026-02-16 10:00:30] Mid=1.0000 Spread=0.020% Depth=42K/44K HEALTHY
82  [2026-02-16 10:01:00] Mid=0.9999 Spread=0.030% Depth=41K/44K HEALTHY
83  ..."
84)]
85pub struct SummaryArgs {
86    /// Base token symbol (e.g., USDC, DAI) or @label from address book. Quote is USDT.
87    #[arg(default_value = "USDC", value_name = "SYMBOL")]
88    pub pair: String,
89
90    /// Market venue (e.g., binance, biconomy, mexc, okx, eth, solana).
91    /// Use `scope venues list` to see all available venues.
92    #[arg(long, default_value = "binance", value_name = "VENUE")]
93    pub venue: String,
94
95    /// Chain for DEX venues (ethereum or solana). Ignored for CEX.
96    #[arg(long, default_value = "ethereum", value_name = "CHAIN")]
97    pub chain: String,
98
99    /// Peg target (e.g., 1.0 for USD stablecoins).
100    #[arg(long, default_value = "1.0", value_name = "TARGET")]
101    pub peg: f64,
102
103    /// Minimum order book levels per side.
104    #[arg(long, default_value = "6", value_name = "N")]
105    pub min_levels: usize,
106
107    /// Minimum depth per side in quote terms, e.g. USDT.
108    #[arg(long, default_value = "3000", value_name = "USDT")]
109    pub min_depth: f64,
110
111    /// Peg range for outlier filtering (orders outside peg ± range×5 excluded).
112    /// E.g., 0.001 = ±0.5% around peg.
113    #[arg(long, default_value = "0.001", value_name = "RANGE")]
114    pub peg_range: f64,
115
116    /// Min bid/ask depth ratio (warn if ratio below this).
117    #[arg(long, default_value = "0.2", value_name = "RATIO")]
118    pub min_bid_ask_ratio: f64,
119
120    /// Max bid/ask depth ratio (warn if ratio above this).
121    #[arg(long, default_value = "5.0", value_name = "RATIO")]
122    pub max_bid_ask_ratio: f64,
123
124    /// Output format.
125    #[arg(short, long, default_value = "text")]
126    pub format: SummaryFormat,
127
128    /// Run repeatedly at this interval (e.g., 30s, 5m, 1h).
129    /// Default when in repeat mode: 60s.
130    #[arg(long, value_name = "INTERVAL")]
131    pub every: Option<String>,
132
133    /// Run for this total duration (e.g., 10m, 1h, 24h).
134    /// Default when in repeat mode: 1h.
135    #[arg(long, value_name = "DURATION")]
136    pub duration: Option<String>,
137
138    /// Save markdown report to file (one-shot mode) or final report (repeat mode).
139    #[arg(long, value_name = "PATH")]
140    pub report: Option<std::path::PathBuf>,
141
142    /// Append time-series CSV of peg/spread/depth to this path (repeat mode only).
143    #[arg(long, value_name = "PATH")]
144    pub csv: Option<std::path::PathBuf>,
145}
146
147#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
148pub enum SummaryFormat {
149    /// Human-readable text report (default).
150    #[default]
151    Text,
152    /// JSON for programmatic consumption.
153    Json,
154}
155
156/// Arguments for `scope market ohlc`.
157#[derive(Debug, Args)]
158#[command(
159    after_help = "\x1b[1mExamples:\x1b[0m
160  scope market ohlc BTC
161  scope market ohlc DAI --venue binance --interval 1d
162  scope market ohlc ETH --venue mexc --limit 50 --format json",
163    after_long_help = "\x1b[1mExamples:\x1b[0m
164
165  \x1b[1m$ scope market ohlc BTC --limit 5\x1b[0m
166
167  OHLC -- BTCUSDT (binance) interval=1h limit=5
168  --------------------------------------------------------
169            Open Time          Open         High          Low        Close         Volume
170  --------------------------------------------------------
171    2026-02-16 09:00  97250.120000  97380.540000  97210.980000  97345.670000        1234.56
172    2026-02-16 08:00  97100.890000  97260.120000  97080.340000  97250.120000        1456.78
173    2026-02-16 07:00  96950.230000  97120.890000  96920.560000  97100.890000        1678.90
174    ...
175
176    5 candles returned
177
178  \x1b[1m$ scope market ohlc BTC --format json --limit 2\x1b[0m
179
180  [
181    {
182      \"open_time\": 1739696400000,
183      \"open\": 97250.12,
184      \"high\": 97380.54,
185      \"low\": 97210.98,
186      \"close\": 97345.67,
187      \"volume\": 1234.56,
188      \"close_time\": null
189    },
190    ...
191  ]"
192)]
193pub struct OhlcArgs {
194    /// Trading pair symbol (e.g., USDC, BTC). Quote is USDT by default.
195    #[arg(default_value = "USDC", value_name = "SYMBOL")]
196    pub pair: String,
197
198    /// Exchange venue (e.g., binance, mexc, bybit).
199    #[arg(long, default_value = "binance", value_name = "VENUE")]
200    pub venue: String,
201
202    /// Candle interval (e.g., 1m, 5m, 15m, 1h, 4h, 1d).
203    #[arg(long, default_value = "1h", value_name = "INTERVAL")]
204    pub interval: String,
205
206    /// Maximum number of candles to fetch.
207    #[arg(long, default_value = "100", value_name = "LIMIT")]
208    pub limit: u32,
209
210    /// Output format.
211    #[arg(long, default_value = "text")]
212    pub format: OhlcFormat,
213}
214
215/// Arguments for `scope market trades`.
216#[derive(Debug, Args)]
217#[command(after_help = "\x1b[1mExamples:\x1b[0m
218  scope market trades BTC
219  scope market trades DAI --venue binance --limit 20
220  scope market trades ETH --venue okx --format json")]
221pub struct TradesArgs {
222    /// Trading pair symbol (e.g., USDC, BTC). Quote is USDT by default.
223    #[arg(default_value = "USDC", value_name = "SYMBOL")]
224    pub pair: String,
225
226    /// Exchange venue (e.g., binance, mexc, bybit).
227    #[arg(long, default_value = "binance", value_name = "VENUE")]
228    pub venue: String,
229
230    /// Maximum number of trades to fetch.
231    #[arg(long, default_value = "50", value_name = "LIMIT")]
232    pub limit: u32,
233
234    /// Output format.
235    #[arg(long, default_value = "text")]
236    pub format: OhlcFormat,
237}
238
239/// Output format for market data commands.
240#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
241pub enum OhlcFormat {
242    /// Human-readable text table (default).
243    #[default]
244    Text,
245    /// JSON for programmatic consumption.
246    Json,
247}
248
249/// Run the market command.
250pub async fn run(
251    args: MarketCommands,
252    _config: &Config,
253    factory: &dyn ChainClientFactory,
254) -> Result<()> {
255    match args {
256        MarketCommands::Summary(summary_args) => run_summary(summary_args, factory).await,
257        MarketCommands::Ohlc(ohlc_args) => run_ohlc(ohlc_args).await,
258        MarketCommands::Trades(trades_args) => run_trades(trades_args).await,
259    }
260}
261
262/// Parse duration strings like "30s", "5m", "1h", "24h" into seconds.
263/// Extract base symbol from pair (e.g. "DAI_USDT" -> "DAI", "USDCUSDT" -> "USDC", "USDC" -> "USDC").
264fn base_symbol_from_pair(pair: &str) -> &str {
265    let p = pair.trim();
266    if let Some(i) = p.find("_USDT") {
267        return &p[..i];
268    }
269    if let Some(i) = p.find("_usdt") {
270        return &p[..i];
271    }
272    if let Some(i) = p.find("/USDT") {
273        return &p[..i];
274    }
275    if p.to_uppercase().ends_with("USDT") && p.len() > 4 {
276        return &p[..p.len() - 4];
277    }
278    p
279}
280
281fn parse_duration(s: &str) -> Result<u64> {
282    let s = s.trim();
283    if s.is_empty() {
284        return Err(ScopeError::Chain("Empty duration".to_string()));
285    }
286    let (num_str, unit) = s
287        .char_indices()
288        .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
289        .map(|(i, _)| (&s[..i], s[i..].trim()))
290        .unwrap_or((s, "s"));
291
292    let num: f64 = num_str
293        .parse()
294        .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
295
296    if num <= 0.0 {
297        return Err(ScopeError::Chain("Duration must be positive".to_string()));
298    }
299
300    let secs = match unit.to_lowercase().as_str() {
301        "s" | "sec" | "secs" | "second" | "seconds" => num,
302        "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
303        "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
304        "d" | "day" | "days" => num * 86400.0,
305        _ => {
306            return Err(ScopeError::Chain(format!(
307                "Unknown duration unit: {}",
308                unit
309            )));
310        }
311    };
312
313    Ok(secs as u64)
314}
315
316/// Builds markdown report content for a market summary.
317fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
318    let bid_dev = summary
319        .best_bid
320        .map(|b| (b - summary.peg_target) / summary.peg_target * 100.0);
321    let ask_dev = summary
322        .best_ask
323        .map(|a| (a - summary.peg_target) / summary.peg_target * 100.0);
324    let volume_row = summary
325        .volume_24h
326        .map(|v| format!("| Volume (24h) | {:.0} USDT |  \n", v))
327        .unwrap_or_default();
328    let exec_buy = summary
329        .execution_10k_buy
330        .as_ref()
331        .map(|e| {
332            if e.fillable {
333                format!("{:.2} bps", e.slippage_bps)
334            } else {
335                "insufficient".to_string()
336            }
337        })
338        .unwrap_or_else(|| "-".to_string());
339    let exec_sell = summary
340        .execution_10k_sell
341        .as_ref()
342        .map(|e| {
343            if e.fillable {
344                format!("{:.2} bps", e.slippage_bps)
345            } else {
346                "insufficient".to_string()
347            }
348        })
349        .unwrap_or_else(|| "-".to_string());
350    let mut md = format!(
351        "# Market Health Report: {}  \n\
352        **Venue:** {}  \n\
353        **Generated:** {}  \n\n\
354        ## Peg & Spread  \n\
355        | Metric | Value |  \n\
356        |--------|-------|  \n\
357        | Peg Target | {:.4} |  \n\
358        | Best Bid | {} |  \n\
359        | Best Ask | {} |  \n\
360        | Mid Price | {} |  \n\
361        | Spread | {} |  \n\
362        {}\
363        | 10k Buy Slippage | {} |  \n\
364        | 10k Sell Slippage | {} |  \n\
365        | Bid Depth | {:.0} |  \n\
366        | Ask Depth | {:.0} |  \n\
367        | Healthy | {} |  \n\n\
368        ## Health Checks  \n",
369        pair,
370        venue,
371        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
372        summary.peg_target,
373        summary
374            .best_bid
375            .map(|b| format!("{:.4} ({:+.3}%)", b, bid_dev.unwrap_or(0.0)))
376            .unwrap_or_else(|| "-".to_string()),
377        summary
378            .best_ask
379            .map(|a| format!("{:.4} ({:+.3}%)", a, ask_dev.unwrap_or(0.0)))
380            .unwrap_or_else(|| "-".to_string()),
381        summary
382            .mid_price
383            .map(|m| format!("{:.4}", m))
384            .unwrap_or_else(|| "-".to_string()),
385        summary
386            .spread
387            .map(|s| format!("{:.4}", s))
388            .unwrap_or_else(|| "-".to_string()),
389        volume_row,
390        exec_buy,
391        exec_sell,
392        summary.bid_depth,
393        summary.ask_depth,
394        if summary.healthy { "✓" } else { "✗" }
395    );
396    for check in &summary.checks {
397        let (icon, msg) = match check {
398            crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
399            crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
400        };
401        md.push_str(&format!("- {} {}\n", icon, msg));
402    }
403    md.push_str(&crate::display::report::report_footer());
404    md
405}
406
407/// Whether the venue string refers to a DEX venue (handled by DexScreener, not the registry).
408fn is_dex_venue(venue: &str) -> bool {
409    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
410}
411
412/// Resolve DEX venue name to a canonical chain name.
413fn dex_venue_to_chain(venue: &str) -> &str {
414    match venue.to_lowercase().as_str() {
415        "ethereum" | "eth" => "ethereum",
416        "solana" => "solana",
417        _ => "ethereum",
418    }
419}
420
421async fn fetch_book_and_volume(
422    args: &SummaryArgs,
423    factory: &dyn ChainClientFactory,
424) -> Result<(OrderBook, Option<f64>)> {
425    let base = base_symbol_from_pair(&args.pair).to_string();
426
427    if is_dex_venue(&args.venue) {
428        // DEX path: synthesize from DexScreener analytics
429        let chain = dex_venue_to_chain(&args.venue);
430        let analytics =
431            crawl::fetch_analytics_for_input(&base, chain, Period::Hour24, 10, factory, None)
432                .await?;
433        if analytics.dex_pairs.is_empty() {
434            return Err(ScopeError::Chain(format!(
435                "No DEX pairs found for {} on {}",
436                base, chain
437            )));
438        }
439        let best_pair = analytics
440            .dex_pairs
441            .iter()
442            .max_by(|a, b| {
443                a.liquidity_usd
444                    .partial_cmp(&b.liquidity_usd)
445                    .unwrap_or(std::cmp::Ordering::Equal)
446            })
447            .ok_or_else(|| ScopeError::Chain("No DEX pairs after filter".to_string()))?;
448        let book = order_book_from_analytics(chain, best_pair, &analytics.token.symbol);
449        let volume = Some(best_pair.volume_24h);
450        Ok((book, volume))
451    } else {
452        // CEX path: use VenueRegistry + ExchangeClient
453        let registry = VenueRegistry::load()?;
454        let exchange = registry.create_exchange_client(&args.venue)?;
455        let pair = exchange.format_pair(&base);
456        let book = exchange.fetch_order_book(&pair).await?;
457
458        // Get volume from ticker if available
459        let volume = if exchange.has_ticker() {
460            exchange
461                .fetch_ticker(&pair)
462                .await
463                .ok()
464                .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
465        } else {
466            None
467        };
468        Ok((book, volume))
469    }
470}
471
472async fn run_summary_once(
473    args: &SummaryArgs,
474    factory: &dyn ChainClientFactory,
475    thresholds: &HealthThresholds,
476    run_num: Option<u64>,
477) -> Result<MarketSummary> {
478    if let Some(n) = run_num {
479        let ts = chrono::Utc::now().format("%H:%M:%S");
480        eprintln!("  --- Run #{} at {} ---\n", n, ts);
481    }
482
483    let (book, volume_24h) = fetch_book_and_volume(args, factory).await?;
484    let summary = MarketSummary::from_order_book(&book, args.peg, thresholds, volume_24h);
485
486    let venue_label = args.venue.clone();
487
488    match args.format {
489        SummaryFormat::Text => {
490            print!("{}", summary.format_text(Some(&venue_label)));
491        }
492        SummaryFormat::Json => {
493            let json = serde_json::json!({
494                "run": run_num,
495                "venue": venue_label,
496                "pair": summary.pair,
497                "peg_target": summary.peg_target,
498                "best_bid": summary.best_bid,
499                "best_ask": summary.best_ask,
500                "mid_price": summary.mid_price,
501                "spread": summary.spread,
502                "volume_24h": summary.volume_24h,
503                "execution_10k_buy": summary.execution_10k_buy.as_ref().map(|e| serde_json::json!({
504                    "fillable": e.fillable,
505                    "slippage_bps": e.slippage_bps
506                })),
507                "execution_10k_sell": summary.execution_10k_sell.as_ref().map(|e| serde_json::json!({
508                    "fillable": e.fillable,
509                    "slippage_bps": e.slippage_bps
510                })),
511                "ask_depth": summary.ask_depth,
512                "bid_depth": summary.bid_depth,
513                "ask_levels": summary.asks.len(),
514                "bid_levels": summary.bids.len(),
515                "healthy": summary.healthy,
516                "checks": summary.checks.iter().map(|c| match c {
517                    crate::market::HealthCheck::Pass(m) => serde_json::json!({"status": "pass", "message": m}),
518                    crate::market::HealthCheck::Fail(m) => serde_json::json!({"status": "fail", "message": m}),
519                }).collect::<Vec<_>>(),
520            });
521            println!("{}", serde_json::to_string_pretty(&json)?);
522        }
523    }
524
525    Ok(summary)
526}
527
528async fn run_summary(args: SummaryArgs, factory: &dyn ChainClientFactory) -> Result<()> {
529    let thresholds = HealthThresholds {
530        peg_target: args.peg,
531        peg_range: args.peg_range,
532        min_levels: args.min_levels,
533        min_depth: args.min_depth,
534        min_bid_ask_ratio: args.min_bid_ask_ratio,
535        max_bid_ask_ratio: args.max_bid_ask_ratio,
536    };
537
538    let repeat_mode = args.every.is_some() || args.duration.is_some();
539
540    if !repeat_mode {
541        let summary = run_summary_once(&args, factory, &thresholds, None).await?;
542        if let Some(ref report_path) = args.report {
543            let venue_label = args.venue.clone();
544            let md = market_summary_to_markdown(&summary, &venue_label, &args.pair);
545            std::fs::write(report_path, md)?;
546            eprintln!("\nReport saved to: {}", report_path.display());
547        }
548        return Ok(());
549    }
550
551    let every_secs = args
552        .every
553        .as_ref()
554        .map(|s| parse_duration(s))
555        .transpose()?
556        .unwrap_or(DEFAULT_EVERY_SECS);
557
558    let duration_secs = args
559        .duration
560        .as_ref()
561        .map(|s| parse_duration(s))
562        .transpose()?
563        .unwrap_or(DEFAULT_DURATION_SECS);
564
565    if every_secs == 0 {
566        return Err(ScopeError::Chain("Interval must be positive".to_string()));
567    }
568
569    let every = Duration::from_secs(every_secs);
570    let start = std::time::Instant::now();
571    let duration = Duration::from_secs(duration_secs);
572
573    eprintln!(
574        "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
575        every_secs, duration_secs
576    );
577
578    let mut run_num: u64 = 1;
579    #[allow(unused_assignments)]
580    let mut last_summary: Option<MarketSummary> = None;
581
582    // Initialize CSV if requested
583    if let Some(ref csv_path) = args.csv {
584        let header =
585            "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy\n";
586        std::fs::write(csv_path, header)?;
587    }
588
589    loop {
590        let summary = run_summary_once(&args, factory, &thresholds, Some(run_num)).await?;
591        last_summary = Some(summary.clone());
592
593        // Append CSV row
594        if let Some(ref csv_path) = args.csv {
595            let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
596            let bid = summary
597                .best_bid
598                .map(|v| v.to_string())
599                .unwrap_or_else(|| "-".to_string());
600            let ask = summary
601                .best_ask
602                .map(|v| v.to_string())
603                .unwrap_or_else(|| "-".to_string());
604            let mid = summary
605                .mid_price
606                .map(|v| v.to_string())
607                .unwrap_or_else(|| "-".to_string());
608            let spread = summary
609                .spread
610                .map(|v| v.to_string())
611                .unwrap_or_else(|| "-".to_string());
612            let row = format!(
613                "{},{},{},{},{},{},{},{},{}\n",
614                ts,
615                run_num,
616                bid,
617                ask,
618                mid,
619                spread,
620                summary.bid_depth,
621                summary.ask_depth,
622                summary.healthy
623            );
624            let mut f = std::fs::OpenOptions::new().append(true).open(csv_path)?;
625            use std::io::Write;
626            f.write_all(row.as_bytes())?;
627        }
628
629        if start.elapsed() >= duration {
630            eprintln!("\nCompleted {} run(s) over {}s.", run_num, duration_secs);
631            break;
632        }
633
634        run_num += 1;
635
636        let remaining = duration.saturating_sub(start.elapsed());
637        let sleep_duration = if remaining < every { remaining } else { every };
638        tokio::time::sleep(sleep_duration).await;
639    }
640
641    // Save final report if requested (last_summary always set when loop runs)
642    if let (Some(ref report_path), Some(summary)) = (args.report, last_summary.as_ref()) {
643        let venue_label = args.venue.clone();
644        let md = market_summary_to_markdown(summary, &venue_label, &args.pair);
645        std::fs::write(report_path, md)?;
646        eprintln!("Report saved to: {}", report_path.display());
647    }
648    if let Some(ref csv_path) = args.csv {
649        eprintln!("Time-series CSV saved to: {}", csv_path.display());
650    }
651
652    Ok(())
653}
654
655// =============================================================================
656// OHLC Command
657// =============================================================================
658
659/// Execute the `scope market ohlc` command.
660async fn run_ohlc(args: OhlcArgs) -> Result<()> {
661    let registry = VenueRegistry::load()?;
662    let descriptor = registry.get(&args.venue).ok_or_else(|| {
663        ScopeError::NotFound(format!(
664            "Venue '{}' not found. Use `scope venues list` to see available venues.",
665            args.venue
666        ))
667    })?;
668
669    let client = crate::market::ExchangeClient::from_descriptor(descriptor);
670    let pair = client.format_pair(base_symbol_from_pair(&args.pair));
671
672    let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
673
674    match args.format {
675        OhlcFormat::Json => {
676            let json_candles: Vec<serde_json::Value> = candles
677                .iter()
678                .map(|c| {
679                    serde_json::json!({
680                        "open_time": c.open_time,
681                        "open": c.open,
682                        "high": c.high,
683                        "low": c.low,
684                        "close": c.close,
685                        "volume": c.volume,
686                        "close_time": c.close_time,
687                    })
688                })
689                .collect();
690            let json_str = serde_json::to_string_pretty(&json_candles)
691                .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
692            println!("{json_str}");
693        }
694        OhlcFormat::Text => {
695            println!(
696                "{}",
697                t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
698            );
699            println!("{}", t::kv_row("Interval", &args.interval));
700            println!("{}", t::kv_row("Limit", &args.limit.to_string()));
701
702            let cols = [
703                t::Col {
704                    label: "Open Time",
705                    width: 19,
706                    align: '>',
707                },
708                t::Col {
709                    label: "Open",
710                    width: 12,
711                    align: '>',
712                },
713                t::Col {
714                    label: "High",
715                    width: 12,
716                    align: '>',
717                },
718                t::Col {
719                    label: "Low",
720                    width: 12,
721                    align: '>',
722                },
723                t::Col {
724                    label: "Close",
725                    width: 12,
726                    align: '>',
727                },
728                t::Col {
729                    label: "Volume",
730                    width: 14,
731                    align: '>',
732                },
733            ];
734            println!("{}", t::table_header(&cols));
735
736            for c in &candles {
737                let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
738                    .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
739                    .unwrap_or_else(|| format!("{}", c.open_time));
740                let open_str = format!("{:.6}", c.open);
741                let high_str = format!("{:.6}", c.high);
742                let low_str = format!("{:.6}", c.low);
743                let close_str = format!("{:.6}", c.close);
744                let volume_str = format!("{:.2}", c.volume);
745                let values = [
746                    dt.as_str(),
747                    open_str.as_str(),
748                    high_str.as_str(),
749                    low_str.as_str(),
750                    close_str.as_str(),
751                    volume_str.as_str(),
752                ];
753                println!("{}", t::table_row(&cols, &values));
754            }
755
756            println!(
757                "{}",
758                t::info_row(&format!("{} candles returned", candles.len()))
759            );
760            println!("{}", t::section_footer());
761        }
762    }
763    Ok(())
764}
765
766// =============================================================================
767// Trades Command
768// =============================================================================
769
770/// Execute the `scope market trades` command.
771async fn run_trades(args: TradesArgs) -> Result<()> {
772    let registry = VenueRegistry::load()?;
773    let descriptor = registry.get(&args.venue).ok_or_else(|| {
774        ScopeError::NotFound(format!(
775            "Venue '{}' not found. Use `scope venues list` to see available venues.",
776            args.venue
777        ))
778    })?;
779
780    let client = crate::market::ExchangeClient::from_descriptor(descriptor);
781    let pair = client.format_pair(base_symbol_from_pair(&args.pair));
782
783    let trades = client.fetch_recent_trades(&pair, args.limit).await?;
784
785    match args.format {
786        OhlcFormat::Json => {
787            let json_trades: Vec<serde_json::Value> = trades
788                .iter()
789                .map(|t| {
790                    serde_json::json!({
791                        "price": t.price,
792                        "quantity": t.quantity,
793                        "quote_quantity": t.quote_quantity,
794                        "timestamp_ms": t.timestamp_ms,
795                        "side": format!("{:?}", t.side),
796                    })
797                })
798                .collect();
799            let json_str = serde_json::to_string_pretty(&json_trades)
800                .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
801            println!("{json_str}");
802        }
803        OhlcFormat::Text => {
804            println!(
805                "{}",
806                t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
807            );
808
809            let cols = [
810                t::Col {
811                    label: "Time",
812                    width: 10,
813                    align: '>',
814                },
815                t::Col {
816                    label: "Side",
817                    width: 5,
818                    align: '>',
819                },
820                t::Col {
821                    label: "Price",
822                    width: 12,
823                    align: '>',
824                },
825                t::Col {
826                    label: "Qty",
827                    width: 12,
828                    align: '>',
829                },
830            ];
831            println!("{}", t::table_header(&cols));
832
833            for t in &trades {
834                let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
835                    .map(|d| d.format("%H:%M:%S").to_string())
836                    .unwrap_or_else(|| "?".to_string());
837                let side_str = match t.side {
838                    crate::market::TradeSide::Buy => "BUY",
839                    crate::market::TradeSide::Sell => "SELL",
840                };
841                let price_str = format!("{:.6}", t.price);
842                let qty_str = format!("{:.2}", t.quantity);
843                let values = [
844                    time.as_str(),
845                    side_str,
846                    price_str.as_str(),
847                    qty_str.as_str(),
848                ];
849                println!("{}", t::table_row(&cols, &values));
850            }
851
852            println!(
853                "{}",
854                t::info_row(&format!("{} trades returned", trades.len()))
855            );
856            println!("{}", t::section_footer());
857        }
858    }
859    Ok(())
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use crate::chains::DefaultClientFactory;
866
867    /// Helper to create a mock venue YAML pointing at the given mock server URL.
868    /// Writes a temporary venue descriptor to the user venues directory so the
869    /// registry picks it up. Returns the venue id.
870    #[allow(dead_code)]
871    fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
872        let venue_id = format!("test_mock_{}", std::process::id());
873        let yaml = format!(
874            r#"
875id: {venue_id}
876name: Test Mock Venue
877base_url: {server_url}
878timeout_secs: 5
879symbol:
880  template: "{{base}}_{{quote}}"
881  default_quote: USDT
882capabilities:
883  order_book:
884    path: /api/v1/depth
885    params:
886      symbol: "{{pair}}"
887    response:
888      asks_key: asks
889      bids_key: bids
890      level_format: positional
891  ticker:
892    path: /api/v1/ticker
893    params:
894      symbol: "{{pair}}"
895    response:
896      last_price: last
897      volume_24h: vol
898"#
899        );
900        let dir = tempfile::tempdir().unwrap();
901        let file_path = dir.path().join(format!("{}.yaml", venue_id));
902        std::fs::write(&file_path, yaml).unwrap();
903        // We can't easily inject into the registry, so instead
904        // create a ConfigurableExchangeClient directly in tests.
905        (venue_id, dir)
906    }
907
908    #[tokio::test]
909    async fn test_run_summary_with_mock_orderbook() {
910        // This test uses the DEX path since mock HTTP with the registry is complex.
911        // Tested in integration tests and exchange module unit tests instead.
912        // Test duration parsing and summary formatting here.
913        let args = SummaryArgs {
914            pair: "USDC".to_string(),
915            venue: "eth".to_string(),
916            chain: "ethereum".to_string(),
917            peg: 1.0,
918            min_levels: 1,
919            min_depth: 50.0,
920            peg_range: 0.01,
921            min_bid_ask_ratio: 0.1,
922            max_bid_ask_ratio: 10.0,
923            format: SummaryFormat::Text,
924            every: None,
925            duration: None,
926            report: None,
927            csv: None,
928        };
929
930        let http: std::sync::Arc<dyn crate::http::HttpClient> =
931            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
932        let factory = DefaultClientFactory {
933            chains_config: Default::default(),
934            http,
935        };
936        // DEX path: will hit real DexScreener API, may fail in offline environments
937        let _result = run_summary(args, &factory).await;
938        // We don't assert success because it depends on network, just confirm no panic
939    }
940
941    #[tokio::test]
942    async fn test_run_summary_json_format() {
943        let args = SummaryArgs {
944            pair: "USDC".to_string(),
945            venue: "eth".to_string(),
946            chain: "ethereum".to_string(),
947            peg: 1.0,
948            min_levels: 1,
949            min_depth: 50.0,
950            peg_range: 0.01,
951            min_bid_ask_ratio: 0.1,
952            max_bid_ask_ratio: 10.0,
953            format: SummaryFormat::Json,
954            every: None,
955            duration: None,
956            report: None,
957            csv: None,
958        };
959
960        let http: std::sync::Arc<dyn crate::http::HttpClient> =
961            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
962        let factory = DefaultClientFactory {
963            chains_config: Default::default(),
964            http,
965        };
966        let _result = run_summary(args, &factory).await;
967    }
968
969    #[test]
970    fn test_parse_duration_seconds() {
971        assert_eq!(parse_duration("30s").unwrap(), 30);
972        assert_eq!(parse_duration("1").unwrap(), 1);
973        assert_eq!(parse_duration("60sec").unwrap(), 60);
974    }
975
976    #[test]
977    fn test_parse_duration_minutes() {
978        assert_eq!(parse_duration("5m").unwrap(), 300);
979        assert_eq!(parse_duration("1min").unwrap(), 60);
980        assert_eq!(parse_duration("2.5m").unwrap(), 150);
981    }
982
983    #[test]
984    fn test_parse_duration_hours() {
985        assert_eq!(parse_duration("1h").unwrap(), 3600);
986        assert_eq!(parse_duration("24h").unwrap(), 86400);
987    }
988
989    #[test]
990    fn test_parse_duration_invalid() {
991        assert!(parse_duration("").is_err());
992        assert!(parse_duration("abc").is_err());
993        assert!(parse_duration("30x").is_err());
994    }
995
996    #[test]
997    fn test_parse_duration_unknown_unit_error_message() {
998        let result = parse_duration("30z");
999        assert!(result.is_err());
1000        let err = result.unwrap_err();
1001        assert!(err.to_string().contains("Unknown duration unit"));
1002        assert!(err.to_string().contains("z"));
1003    }
1004
1005    #[test]
1006    fn test_parse_duration_invalid_number_error() {
1007        let result = parse_duration("abc30s");
1008        assert!(result.is_err());
1009    }
1010
1011    #[test]
1012    fn test_parse_duration_non_positive() {
1013        assert!(parse_duration("0").is_err());
1014        assert!(parse_duration("-5s").is_err());
1015    }
1016
1017    // ====================================================================
1018    // base_symbol_from_pair tests
1019    // ====================================================================
1020
1021    #[test]
1022    fn test_base_symbol_from_pair_underscore() {
1023        assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1024        assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
1025    }
1026
1027    #[test]
1028    fn test_base_symbol_from_pair_lowercase_underscore() {
1029        assert_eq!(base_symbol_from_pair("dai_usdt"), "dai");
1030    }
1031
1032    #[test]
1033    fn test_base_symbol_from_pair_slash() {
1034        assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
1035    }
1036
1037    #[test]
1038    fn test_base_symbol_from_pair_concat() {
1039        assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
1040        assert_eq!(base_symbol_from_pair("DAIUSDT"), "DAI");
1041    }
1042
1043    #[test]
1044    fn test_base_symbol_from_pair_plain() {
1045        assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1046        assert_eq!(base_symbol_from_pair("ETH"), "ETH");
1047    }
1048
1049    #[test]
1050    fn test_base_symbol_from_pair_whitespace() {
1051        assert_eq!(base_symbol_from_pair("  DAI_USDT  "), "DAI");
1052    }
1053
1054    // ====================================================================
1055    // market_summary_to_markdown tests
1056    // ====================================================================
1057
1058    #[test]
1059    fn test_market_summary_to_markdown_basic() {
1060        use crate::market::{HealthCheck, MarketSummary};
1061        let summary = MarketSummary {
1062            pair: "USDCUSDT".to_string(),
1063            peg_target: 1.0,
1064            best_bid: Some(0.9999),
1065            best_ask: Some(1.0001),
1066            mid_price: Some(1.0000),
1067            spread: Some(0.0002),
1068            volume_24h: Some(1_000_000.0),
1069            bid_depth: 50_000.0,
1070            ask_depth: 50_000.0,
1071            bid_outliers: 0,
1072            ask_outliers: 0,
1073            healthy: true,
1074            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1075            execution_10k_buy: None,
1076            execution_10k_sell: None,
1077            asks: vec![],
1078            bids: vec![],
1079        };
1080        let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1081        assert!(md.contains("Market Health Report"));
1082        assert!(md.contains("USDCUSDT"));
1083        assert!(md.contains("Binance"));
1084        assert!(md.contains("Peg Target"));
1085        assert!(md.contains("1.0000"));
1086        assert!(md.contains("Healthy"));
1087    }
1088
1089    #[test]
1090    fn test_market_summary_to_markdown_no_prices() {
1091        use crate::market::{HealthCheck, MarketSummary};
1092        let summary = MarketSummary {
1093            pair: "TESTUSDT".to_string(),
1094            peg_target: 1.0,
1095            best_bid: None,
1096            best_ask: None,
1097            mid_price: None,
1098            spread: None,
1099            volume_24h: None,
1100            bid_depth: 0.0,
1101            ask_depth: 0.0,
1102            bid_outliers: 0,
1103            ask_outliers: 0,
1104            healthy: false,
1105            checks: vec![HealthCheck::Fail("No data".to_string())],
1106            execution_10k_buy: None,
1107            execution_10k_sell: None,
1108            asks: vec![],
1109            bids: vec![],
1110        };
1111        let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1112        assert!(md.contains("Market Health Report"));
1113        assert!(md.contains("-")); // missing data shown as "-"
1114    }
1115
1116    // ====================================================================
1117    // parse_duration — additional edge cases
1118    // ====================================================================
1119
1120    #[test]
1121    fn test_parse_duration_days() {
1122        assert_eq!(parse_duration("1d").unwrap(), 86400);
1123        assert_eq!(parse_duration("7d").unwrap(), 604800);
1124        assert_eq!(parse_duration("1day").unwrap(), 86400);
1125        assert_eq!(parse_duration("2days").unwrap(), 172800);
1126    }
1127
1128    #[test]
1129    fn test_parse_duration_long_names() {
1130        assert_eq!(parse_duration("30seconds").unwrap(), 30);
1131        assert_eq!(parse_duration("5minutes").unwrap(), 300);
1132        assert_eq!(parse_duration("2hours").unwrap(), 7200);
1133    }
1134
1135    #[test]
1136    fn test_parse_duration_fractional() {
1137        assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1138        assert_eq!(parse_duration("1.5m").unwrap(), 90);
1139    }
1140
1141    // ====================================================================
1142    // SummaryFormat tests
1143    // ====================================================================
1144
1145    #[test]
1146    fn test_summary_format_default() {
1147        let fmt = SummaryFormat::default();
1148        assert!(matches!(fmt, SummaryFormat::Text));
1149    }
1150
1151    #[test]
1152    fn test_summary_format_debug() {
1153        let text = format!("{:?}", SummaryFormat::Text);
1154        assert_eq!(text, "Text");
1155        let json = format!("{:?}", SummaryFormat::Json);
1156        assert_eq!(json, "Json");
1157    }
1158
1159    // ====================================================================
1160    // MarketCommands parsing tests
1161    // ====================================================================
1162
1163    #[test]
1164    fn test_ohlc_args_deserialization() {
1165        use crate::cli::{Cli, Commands};
1166        use clap::Parser;
1167        let cli = Cli::try_parse_from([
1168            "scope",
1169            "market",
1170            "ohlc",
1171            "USDC",
1172            "--venue",
1173            "binance",
1174            "--interval",
1175            "1h",
1176            "--limit",
1177            "50",
1178        ])
1179        .unwrap();
1180        if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1181            assert_eq!(args.pair, "USDC");
1182            assert_eq!(args.venue, "binance");
1183            assert_eq!(args.interval, "1h");
1184            assert_eq!(args.limit, 50);
1185        } else {
1186            panic!("Expected Market Ohlc command");
1187        }
1188    }
1189
1190    #[test]
1191    fn test_trades_args_deserialization() {
1192        use crate::cli::{Cli, Commands};
1193        use clap::Parser;
1194        let cli = Cli::try_parse_from([
1195            "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1196        ])
1197        .unwrap();
1198        if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1199            assert_eq!(args.pair, "BTC");
1200            assert_eq!(args.venue, "mexc");
1201            assert_eq!(args.limit, 100);
1202        } else {
1203            panic!("Expected Market Trades command");
1204        }
1205    }
1206
1207    #[test]
1208    fn test_base_symbol_from_pair_various_inputs() {
1209        // Additional coverage for edge cases
1210        assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1211        assert_eq!(base_symbol_from_pair("BTCUSDT"), "BTC");
1212        assert_eq!(base_symbol_from_pair("ETH/USDT"), "ETH");
1213        assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1214        assert_eq!(base_symbol_from_pair("X"), "X"); // short symbol, no USDT suffix
1215        assert_eq!(base_symbol_from_pair(""), "");
1216    }
1217
1218    #[test]
1219    fn test_summary_args_debug() {
1220        let args = SummaryArgs {
1221            pair: "USDC".to_string(),
1222            venue: "binance".to_string(),
1223            chain: "ethereum".to_string(),
1224            peg: 1.0,
1225            min_levels: 6,
1226            min_depth: 3000.0,
1227            peg_range: 0.001,
1228            min_bid_ask_ratio: 0.2,
1229            max_bid_ask_ratio: 5.0,
1230            format: SummaryFormat::Text,
1231            every: None,
1232            duration: None,
1233            report: None,
1234            csv: None,
1235        };
1236        let debug = format!("{:?}", args);
1237        assert!(debug.contains("SummaryArgs"));
1238        assert!(debug.contains("USDC"));
1239    }
1240
1241    #[test]
1242    fn test_default_constants() {
1243        assert_eq!(DEFAULT_EVERY_SECS, 60);
1244        assert_eq!(DEFAULT_DURATION_SECS, 3600);
1245    }
1246
1247    #[test]
1248    fn test_market_summary_to_markdown_with_execution_estimates() {
1249        use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1250        let summary = MarketSummary {
1251            pair: "TESTUSDT".to_string(),
1252            peg_target: 1.0,
1253            best_bid: Some(0.9999),
1254            best_ask: Some(1.0001),
1255            mid_price: Some(1.0000),
1256            spread: Some(0.0002),
1257            volume_24h: Some(1_000_000.0),
1258            bid_depth: 50_000.0,
1259            ask_depth: 50_000.0,
1260            bid_outliers: 0,
1261            ask_outliers: 0,
1262            healthy: true,
1263            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1264            execution_10k_buy: Some(ExecutionEstimate {
1265                notional_usdt: 10_000.0,
1266                side: ExecutionSide::Buy,
1267                vwap: 1.0001,
1268                slippage_bps: 1.5,
1269                fillable: true,
1270            }),
1271            execution_10k_sell: Some(ExecutionEstimate {
1272                notional_usdt: 10_000.0,
1273                side: ExecutionSide::Sell,
1274                vwap: 0.0,
1275                slippage_bps: 0.0,
1276                fillable: false,
1277            }),
1278            asks: vec![],
1279            bids: vec![],
1280        };
1281        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1282        assert!(md.contains("Market Health Report"));
1283        assert!(md.contains("TESTUSDT"));
1284        assert!(md.contains("TestVenue"));
1285        // Check for fillable buy slippage (should show "1.50 bps")
1286        assert!(md.contains("1.50 bps"));
1287        // Check for unfillable sell (should show "insufficient")
1288        assert!(md.contains("insufficient"));
1289    }
1290
1291    #[tokio::test]
1292    async fn test_run_with_summary_command() {
1293        // Test the run() dispatcher with a DEX venue (doesn't require mock HTTP)
1294        let args = MarketCommands::Summary(SummaryArgs {
1295            pair: "USDC".to_string(),
1296            venue: "eth".to_string(),
1297            chain: "ethereum".to_string(),
1298            peg: 1.0,
1299            min_levels: 1,
1300            min_depth: 50.0,
1301            peg_range: 0.01,
1302            min_bid_ask_ratio: 0.1,
1303            max_bid_ask_ratio: 10.0,
1304            format: SummaryFormat::Text,
1305            every: None,
1306            duration: None,
1307            report: None,
1308            csv: None,
1309        });
1310
1311        let http: std::sync::Arc<dyn crate::http::HttpClient> =
1312            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1313        let factory = DefaultClientFactory {
1314            chains_config: Default::default(),
1315            http,
1316        };
1317        let config = Config::default();
1318        let _result = run(args, &config, &factory).await;
1319        // Don't assert success - depends on network
1320    }
1321
1322    #[test]
1323    fn test_is_dex_venue() {
1324        assert!(is_dex_venue("eth"));
1325        assert!(is_dex_venue("ethereum"));
1326        assert!(is_dex_venue("Ethereum"));
1327        assert!(is_dex_venue("solana"));
1328        assert!(is_dex_venue("Solana"));
1329        assert!(!is_dex_venue("binance"));
1330        assert!(!is_dex_venue("okx"));
1331        assert!(!is_dex_venue("mexc"));
1332    }
1333
1334    #[test]
1335    fn test_dex_venue_to_chain() {
1336        assert_eq!(dex_venue_to_chain("eth"), "ethereum");
1337        assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
1338        assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
1339        assert_eq!(dex_venue_to_chain("solana"), "solana");
1340    }
1341
1342    #[test]
1343    fn test_venue_registry_loaded_in_cex_path() {
1344        // Verify the registry loads and can create an exchange client for any built-in venue
1345        let registry = VenueRegistry::load().unwrap();
1346        assert!(registry.contains("binance"));
1347        let client = registry.create_exchange_client("binance");
1348        assert!(client.is_ok());
1349    }
1350
1351    #[test]
1352    fn test_venue_registry_error_for_unknown() {
1353        let registry = VenueRegistry::load().unwrap();
1354        let result = registry.create_exchange_client("kracken");
1355        assert!(result.is_err());
1356        let err = result.unwrap_err().to_string();
1357        assert!(err.contains("Unknown venue"));
1358        assert!(err.contains("Did you mean")); // should suggest kraken (distance 1)
1359    }
1360
1361    #[tokio::test]
1362    async fn test_run_summary_json_format_with_mock() {
1363        let args = SummaryArgs {
1364            pair: "USDC".to_string(),
1365            venue: "eth".to_string(),
1366            chain: "ethereum".to_string(),
1367            peg: 1.0,
1368            min_levels: 1,
1369            min_depth: 50.0,
1370            peg_range: 0.01,
1371            min_bid_ask_ratio: 0.1,
1372            max_bid_ask_ratio: 10.0,
1373            format: SummaryFormat::Json,
1374            every: None,
1375            duration: None,
1376            report: None,
1377            csv: None,
1378        };
1379
1380        let http: std::sync::Arc<dyn crate::http::HttpClient> =
1381            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1382        let factory = DefaultClientFactory {
1383            chains_config: Default::default(),
1384            http,
1385        };
1386        let _result = run_summary(args, &factory).await;
1387    }
1388
1389    // ====================================================================
1390    // OHLC command tests
1391    // ====================================================================
1392
1393    #[test]
1394    fn test_ohlc_format_default() {
1395        let fmt: OhlcFormat = Default::default();
1396        assert_eq!(fmt, OhlcFormat::Text);
1397    }
1398
1399    #[test]
1400    fn test_ohlc_format_display() {
1401        // ValueEnum-derived parsing
1402        assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1403        assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1404    }
1405
1406    #[test]
1407    fn test_ohlc_args_default_values() {
1408        // Verify we can construct OhlcArgs with defaults
1409        let args = OhlcArgs {
1410            pair: "BTC".to_string(),
1411            venue: "binance".to_string(),
1412            interval: "1h".to_string(),
1413            limit: 100,
1414            format: OhlcFormat::Text,
1415        };
1416        assert_eq!(args.pair, "BTC");
1417        assert_eq!(args.venue, "binance");
1418        assert_eq!(args.interval, "1h");
1419        assert_eq!(args.limit, 100);
1420    }
1421
1422    #[test]
1423    fn test_trades_args_construction() {
1424        let args = TradesArgs {
1425            pair: "ETH".to_string(),
1426            venue: "okx".to_string(),
1427            limit: 50,
1428            format: OhlcFormat::Json,
1429        };
1430        assert_eq!(args.pair, "ETH");
1431        assert_eq!(args.venue, "okx");
1432        assert_eq!(args.limit, 50);
1433    }
1434
1435    #[tokio::test]
1436    async fn test_run_ohlc_unknown_venue() {
1437        let args = OhlcArgs {
1438            pair: "BTC".to_string(),
1439            venue: "nonexistent_venue".to_string(),
1440            interval: "1h".to_string(),
1441            limit: 10,
1442            format: OhlcFormat::Text,
1443        };
1444        let result = run_ohlc(args).await;
1445        assert!(result.is_err());
1446        let err = result.unwrap_err().to_string();
1447        assert!(
1448            err.contains("not found"),
1449            "expected 'not found' error, got: {}",
1450            err
1451        );
1452    }
1453
1454    #[tokio::test]
1455    async fn test_run_trades_unknown_venue() {
1456        let args = TradesArgs {
1457            pair: "BTC".to_string(),
1458            venue: "nonexistent_venue".to_string(),
1459            limit: 10,
1460            format: OhlcFormat::Text,
1461        };
1462        let result = run_trades(args).await;
1463        assert!(result.is_err());
1464        let err = result.unwrap_err().to_string();
1465        assert!(
1466            err.contains("not found"),
1467            "expected 'not found' error, got: {}",
1468            err
1469        );
1470    }
1471
1472    #[tokio::test]
1473    async fn test_run_dispatches_ohlc() {
1474        let cmd = MarketCommands::Ohlc(OhlcArgs {
1475            pair: "BTC".to_string(),
1476            venue: "nonexistent_test_venue".to_string(),
1477            interval: "1h".to_string(),
1478            limit: 5,
1479            format: OhlcFormat::Text,
1480        });
1481        let http: std::sync::Arc<dyn crate::http::HttpClient> =
1482            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1483        let factory = DefaultClientFactory {
1484            chains_config: Default::default(),
1485            http,
1486        };
1487        let config = Config::default();
1488        let result = run(cmd, &config, &factory).await;
1489        // Should error with venue not found
1490        assert!(result.is_err());
1491    }
1492
1493    #[tokio::test]
1494    async fn test_run_dispatches_trades() {
1495        let cmd = MarketCommands::Trades(TradesArgs {
1496            pair: "ETH".to_string(),
1497            venue: "nonexistent_test_venue".to_string(),
1498            limit: 5,
1499            format: OhlcFormat::Json,
1500        });
1501        let http: std::sync::Arc<dyn crate::http::HttpClient> =
1502            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1503        let factory = DefaultClientFactory {
1504            chains_config: Default::default(),
1505            http,
1506        };
1507        let config = Config::default();
1508        let result = run(cmd, &config, &factory).await;
1509        assert!(result.is_err());
1510    }
1511
1512    #[tokio::test]
1513    async fn test_run_ohlc_text_format_with_real_venue() {
1514        // Uses a real venue with a real API call. May succeed or fail depending
1515        // on network availability. Exercises venue resolution and client creation.
1516        let args = OhlcArgs {
1517            pair: "BTC".to_string(),
1518            venue: "binance".to_string(),
1519            interval: "1h".to_string(),
1520            limit: 3,
1521            format: OhlcFormat::Text,
1522        };
1523        let _result = run_ohlc(args).await;
1524        // Don't assert success — depends on network
1525    }
1526
1527    #[tokio::test]
1528    async fn test_run_ohlc_json_format_with_real_venue() {
1529        let args = OhlcArgs {
1530            pair: "ETH".to_string(),
1531            venue: "binance".to_string(),
1532            interval: "15m".to_string(),
1533            limit: 2,
1534            format: OhlcFormat::Json,
1535        };
1536        let _result = run_ohlc(args).await;
1537    }
1538
1539    #[tokio::test]
1540    async fn test_run_trades_text_format_with_real_venue() {
1541        let args = TradesArgs {
1542            pair: "BTC".to_string(),
1543            venue: "binance".to_string(),
1544            limit: 5,
1545            format: OhlcFormat::Text,
1546        };
1547        let _result = run_trades(args).await;
1548    }
1549
1550    #[tokio::test]
1551    async fn test_run_trades_json_format_with_real_venue() {
1552        let args = TradesArgs {
1553            pair: "ETH".to_string(),
1554            venue: "binance".to_string(),
1555            limit: 3,
1556            format: OhlcFormat::Json,
1557        };
1558        let _result = run_trades(args).await;
1559    }
1560
1561    #[tokio::test]
1562    async fn test_run_ohlc_multiple_venues() {
1563        // Exercise venue resolution for several built-in venues
1564        for venue in &["mexc", "okx", "bybit"] {
1565            let args = OhlcArgs {
1566                pair: "BTC".to_string(),
1567                venue: venue.to_string(),
1568                interval: "1h".to_string(),
1569                limit: 2,
1570                format: OhlcFormat::Json,
1571            };
1572            let _result = run_ohlc(args).await;
1573        }
1574    }
1575
1576    #[tokio::test]
1577    async fn test_run_trades_multiple_venues() {
1578        for venue in &["mexc", "okx", "bybit"] {
1579            let args = TradesArgs {
1580                pair: "BTC".to_string(),
1581                venue: venue.to_string(),
1582                limit: 3,
1583                format: OhlcFormat::Text,
1584            };
1585            let _result = run_trades(args).await;
1586        }
1587    }
1588
1589    // ====================================================================
1590    // parse_duration — additional edge cases for full coverage
1591    // ====================================================================
1592
1593    #[test]
1594    fn test_parse_duration_whitespace_empty() {
1595        assert!(parse_duration("").is_err());
1596        assert!(parse_duration("   ").is_err());
1597    }
1598
1599    #[test]
1600    fn test_parse_duration_sec_secs_second_seconds() {
1601        assert_eq!(parse_duration("1sec").unwrap(), 1);
1602        assert_eq!(parse_duration("2secs").unwrap(), 2);
1603        assert_eq!(parse_duration("1second").unwrap(), 1);
1604        assert_eq!(parse_duration("3seconds").unwrap(), 3);
1605    }
1606
1607    #[test]
1608    fn test_parse_duration_minute_minutes() {
1609        assert_eq!(parse_duration("1minute").unwrap(), 60);
1610        assert_eq!(parse_duration("2minutes").unwrap(), 120);
1611    }
1612
1613    #[test]
1614    fn test_parse_duration_hr_hrs_hour_hours() {
1615        assert_eq!(parse_duration("1hr").unwrap(), 3600);
1616        assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1617        assert_eq!(parse_duration("1hour").unwrap(), 3600);
1618        assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1619    }
1620
1621    #[test]
1622    fn test_parse_duration_number_only_defaults_to_seconds() {
1623        assert_eq!(parse_duration("1.5").unwrap(), 1);
1624        assert_eq!(parse_duration("42").unwrap(), 42);
1625    }
1626
1627    #[test]
1628    fn test_parse_duration_trimmed_input() {
1629        assert_eq!(parse_duration("  30s  ").unwrap(), 30);
1630        assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1631    }
1632
1633    #[test]
1634    fn test_parse_duration_invalid_number_format() {
1635        assert!(parse_duration("1.2.3s").is_err());
1636        assert!(parse_duration("abc").is_err());
1637    }
1638
1639    // ====================================================================
1640    // dex_venue_to_chain — unknown venue fallback
1641    // ====================================================================
1642
1643    #[test]
1644    fn test_dex_venue_to_chain_unknown_returns_ethereum() {
1645        assert_eq!(dex_venue_to_chain("binance"), "ethereum");
1646        assert_eq!(dex_venue_to_chain("kraken"), "ethereum");
1647        assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
1648    }
1649
1650    // ====================================================================
1651    // market_summary_to_markdown — mixed Pass/Fail checks
1652    // ====================================================================
1653
1654    #[test]
1655    fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1656        use crate::market::{HealthCheck, MarketSummary};
1657        let summary = MarketSummary {
1658            pair: "TESTUSDT".to_string(),
1659            peg_target: 1.0,
1660            best_bid: Some(0.9999),
1661            best_ask: Some(1.0001),
1662            mid_price: Some(1.0000),
1663            spread: Some(0.0002),
1664            volume_24h: Some(500_000.0),
1665            bid_depth: 40_000.0,
1666            ask_depth: 45_000.0,
1667            bid_outliers: 0,
1668            ask_outliers: 0,
1669            healthy: false,
1670            checks: vec![
1671                HealthCheck::Pass("Spread within range".to_string()),
1672                HealthCheck::Fail("Bid depth below minimum".to_string()),
1673            ],
1674            execution_10k_buy: None,
1675            execution_10k_sell: None,
1676            asks: vec![],
1677            bids: vec![],
1678        };
1679        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1680        assert!(md.contains("✓ Spread within range"));
1681        assert!(md.contains("✗ Bid depth below minimum"));
1682        assert!(md.contains("✗")); // unhealthy indicator in table
1683    }
1684
1685    #[test]
1686    fn test_market_summary_to_markdown_empty_checks() {
1687        use crate::market::MarketSummary;
1688        let summary = MarketSummary {
1689            pair: "X".to_string(),
1690            peg_target: 1.0,
1691            best_bid: Some(1.0),
1692            best_ask: Some(1.0),
1693            mid_price: Some(1.0),
1694            spread: Some(0.0),
1695            volume_24h: None,
1696            bid_depth: 100.0,
1697            ask_depth: 100.0,
1698            bid_outliers: 0,
1699            ask_outliers: 0,
1700            healthy: true,
1701            checks: vec![],
1702            execution_10k_buy: None,
1703            execution_10k_sell: None,
1704            asks: vec![],
1705            bids: vec![],
1706        };
1707        let md = market_summary_to_markdown(&summary, "Venue", "X");
1708        assert!(md.contains("Market Health Report"));
1709        assert!(md.contains("Health Checks"));
1710    }
1711
1712    // ====================================================================
1713    // OhlcFormat / SummaryFormat trait coverage
1714    // ====================================================================
1715
1716    #[test]
1717    fn test_ohlc_format_partial_eq() {
1718        assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1719        assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1720        assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1721    }
1722
1723    #[test]
1724    fn test_summary_format_clone_copy() {
1725        let text = SummaryFormat::Text;
1726        let cloned = text;
1727        assert!(matches!(cloned, SummaryFormat::Text));
1728        assert!(matches!(text, SummaryFormat::Text));
1729    }
1730
1731    // ====================================================================
1732    // run_summary — error paths and report/csv
1733    // ====================================================================
1734
1735    #[tokio::test]
1736    async fn test_run_summary_interval_zero_error() {
1737        let args = SummaryArgs {
1738            pair: "USDC".to_string(),
1739            venue: "eth".to_string(),
1740            chain: "ethereum".to_string(),
1741            peg: 1.0,
1742            min_levels: 1,
1743            min_depth: 50.0,
1744            peg_range: 0.01,
1745            min_bid_ask_ratio: 0.1,
1746            max_bid_ask_ratio: 10.0,
1747            format: SummaryFormat::Text,
1748            every: Some("0.1s".to_string()),
1749            duration: Some("1m".to_string()),
1750            report: None,
1751            csv: None,
1752        };
1753        let http: std::sync::Arc<dyn crate::http::HttpClient> =
1754            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1755        let factory = DefaultClientFactory {
1756            chains_config: Default::default(),
1757            http,
1758        };
1759        let result = run_summary(args, &factory).await;
1760        assert!(result.is_err());
1761        let err = result.unwrap_err().to_string();
1762        assert!(
1763            err.contains("Interval must be positive") || err.contains("positive"),
1764            "expected interval error, got: {}",
1765            err
1766        );
1767    }
1768
1769    #[tokio::test]
1770    async fn test_run_summary_one_shot_with_report() {
1771        let report_dir = tempfile::tempdir().unwrap();
1772        let report_path = report_dir.path().join("report.md");
1773        let args = SummaryArgs {
1774            pair: "USDC".to_string(),
1775            venue: "eth".to_string(),
1776            chain: "ethereum".to_string(),
1777            peg: 1.0,
1778            min_levels: 1,
1779            min_depth: 50.0,
1780            peg_range: 0.01,
1781            min_bid_ask_ratio: 0.1,
1782            max_bid_ask_ratio: 10.0,
1783            format: SummaryFormat::Text,
1784            every: None,
1785            duration: None,
1786            report: Some(report_path.clone()),
1787            csv: None,
1788        };
1789        let http: std::sync::Arc<dyn crate::http::HttpClient> =
1790            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1791        let factory = DefaultClientFactory {
1792            chains_config: Default::default(),
1793            http,
1794        };
1795        let result = run_summary(args, &factory).await;
1796        if result.is_ok() {
1797            let content = std::fs::read_to_string(&report_path).unwrap();
1798            assert!(content.contains("Market Health Report"));
1799        }
1800    }
1801
1802    // ====================================================================
1803    // MarketCommands and struct Debug/construction
1804    // ====================================================================
1805
1806    #[test]
1807    fn test_market_commands_debug() {
1808        let cmd = MarketCommands::Summary(SummaryArgs {
1809            pair: "USDC".to_string(),
1810            venue: "binance".to_string(),
1811            chain: "ethereum".to_string(),
1812            peg: 1.0,
1813            min_levels: 6,
1814            min_depth: 3000.0,
1815            peg_range: 0.001,
1816            min_bid_ask_ratio: 0.2,
1817            max_bid_ask_ratio: 5.0,
1818            format: SummaryFormat::Text,
1819            every: None,
1820            duration: None,
1821            report: None,
1822            csv: None,
1823        });
1824        let debug = format!("{:?}", cmd);
1825        assert!(debug.contains("Summary"));
1826
1827        let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
1828            pair: "BTC".to_string(),
1829            venue: "binance".to_string(),
1830            interval: "1h".to_string(),
1831            limit: 100,
1832            format: OhlcFormat::Text,
1833        });
1834        assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
1835
1836        let trades_cmd = MarketCommands::Trades(TradesArgs {
1837            pair: "ETH".to_string(),
1838            venue: "binance".to_string(),
1839            limit: 50,
1840            format: OhlcFormat::Json,
1841        });
1842        assert!(format!("{:?}", trades_cmd).contains("Trades"));
1843    }
1844
1845    #[test]
1846    fn test_summary_args_with_report_csv_options() {
1847        let args = SummaryArgs {
1848            pair: "DAI".to_string(),
1849            venue: "binance".to_string(),
1850            chain: "ethereum".to_string(),
1851            peg: 1.0,
1852            min_levels: 6,
1853            min_depth: 3000.0,
1854            peg_range: 0.001,
1855            min_bid_ask_ratio: 0.2,
1856            max_bid_ask_ratio: 5.0,
1857            format: SummaryFormat::Json,
1858            every: Some("30s".to_string()),
1859            duration: Some("1h".to_string()),
1860            report: Some(std::path::PathBuf::from("/tmp/report.md")),
1861            csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
1862        };
1863        assert_eq!(args.pair, "DAI");
1864        assert_eq!(args.venue, "binance");
1865        assert!(args.every.is_some());
1866        assert!(args.duration.is_some());
1867        assert!(args.report.is_some());
1868        assert!(args.csv.is_some());
1869    }
1870
1871    #[test]
1872    fn test_base_symbol_from_pair_4char_usdt() {
1873        // "USDT" itself has len 4, so doesn't satisfy p.len() > 4
1874        assert_eq!(base_symbol_from_pair("USDT"), "USDT");
1875    }
1876
1877    #[test]
1878    fn test_market_summary_to_markdown_unhealthy() {
1879        use crate::market::{HealthCheck, MarketSummary};
1880        let summary = MarketSummary {
1881            pair: "X".to_string(),
1882            peg_target: 1.0,
1883            best_bid: Some(0.99),
1884            best_ask: Some(1.01),
1885            mid_price: Some(1.0),
1886            spread: Some(0.02),
1887            volume_24h: None,
1888            bid_depth: 100.0,
1889            ask_depth: 100.0,
1890            bid_outliers: 0,
1891            ask_outliers: 0,
1892            healthy: false,
1893            checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
1894            execution_10k_buy: None,
1895            execution_10k_sell: None,
1896            asks: vec![],
1897            bids: vec![],
1898        };
1899        let md = market_summary_to_markdown(&summary, "Test", "X");
1900        assert!(md.contains("✗"));
1901        assert!(md.contains("Peg deviation too high"));
1902    }
1903
1904    #[test]
1905    fn test_ohlc_format_eq() {
1906        assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1907        assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1908    }
1909
1910    #[test]
1911    fn test_trades_args_default_venue() {
1912        let args = TradesArgs {
1913            pair: "USDC".to_string(),
1914            venue: "binance".to_string(),
1915            limit: 50,
1916            format: OhlcFormat::Text,
1917        };
1918        assert_eq!(args.pair, "USDC");
1919        assert_eq!(args.venue, "binance");
1920    }
1921}