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            .unwrap();
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            println!("{}", serde_json::to_string_pretty(&json_candles).unwrap());
691        }
692        OhlcFormat::Text => {
693            println!(
694                "{}",
695                t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
696            );
697            println!("{}", t::kv_row("Interval", &args.interval));
698            println!("{}", t::kv_row("Limit", &args.limit.to_string()));
699
700            let cols = [
701                t::Col {
702                    label: "Open Time",
703                    width: 19,
704                    align: '>',
705                },
706                t::Col {
707                    label: "Open",
708                    width: 12,
709                    align: '>',
710                },
711                t::Col {
712                    label: "High",
713                    width: 12,
714                    align: '>',
715                },
716                t::Col {
717                    label: "Low",
718                    width: 12,
719                    align: '>',
720                },
721                t::Col {
722                    label: "Close",
723                    width: 12,
724                    align: '>',
725                },
726                t::Col {
727                    label: "Volume",
728                    width: 14,
729                    align: '>',
730                },
731            ];
732            println!("{}", t::table_header(&cols));
733
734            for c in &candles {
735                let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
736                    .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
737                    .unwrap_or_else(|| format!("{}", c.open_time));
738                let open_str = format!("{:.6}", c.open);
739                let high_str = format!("{:.6}", c.high);
740                let low_str = format!("{:.6}", c.low);
741                let close_str = format!("{:.6}", c.close);
742                let volume_str = format!("{:.2}", c.volume);
743                let values = [
744                    dt.as_str(),
745                    open_str.as_str(),
746                    high_str.as_str(),
747                    low_str.as_str(),
748                    close_str.as_str(),
749                    volume_str.as_str(),
750                ];
751                println!("{}", t::table_row(&cols, &values));
752            }
753
754            println!(
755                "{}",
756                t::info_row(&format!("{} candles returned", candles.len()))
757            );
758            println!("{}", t::section_footer());
759        }
760    }
761    Ok(())
762}
763
764// =============================================================================
765// Trades Command
766// =============================================================================
767
768/// Execute the `scope market trades` command.
769async fn run_trades(args: TradesArgs) -> Result<()> {
770    let registry = VenueRegistry::load()?;
771    let descriptor = registry.get(&args.venue).ok_or_else(|| {
772        ScopeError::NotFound(format!(
773            "Venue '{}' not found. Use `scope venues list` to see available venues.",
774            args.venue
775        ))
776    })?;
777
778    let client = crate::market::ExchangeClient::from_descriptor(descriptor);
779    let pair = client.format_pair(base_symbol_from_pair(&args.pair));
780
781    let trades = client.fetch_recent_trades(&pair, args.limit).await?;
782
783    match args.format {
784        OhlcFormat::Json => {
785            let json_trades: Vec<serde_json::Value> = trades
786                .iter()
787                .map(|t| {
788                    serde_json::json!({
789                        "price": t.price,
790                        "quantity": t.quantity,
791                        "quote_quantity": t.quote_quantity,
792                        "timestamp_ms": t.timestamp_ms,
793                        "side": format!("{:?}", t.side),
794                    })
795                })
796                .collect();
797            println!("{}", serde_json::to_string_pretty(&json_trades).unwrap());
798        }
799        OhlcFormat::Text => {
800            println!(
801                "{}",
802                t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
803            );
804
805            let cols = [
806                t::Col {
807                    label: "Time",
808                    width: 10,
809                    align: '>',
810                },
811                t::Col {
812                    label: "Side",
813                    width: 5,
814                    align: '>',
815                },
816                t::Col {
817                    label: "Price",
818                    width: 12,
819                    align: '>',
820                },
821                t::Col {
822                    label: "Qty",
823                    width: 12,
824                    align: '>',
825                },
826            ];
827            println!("{}", t::table_header(&cols));
828
829            for t in &trades {
830                let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
831                    .map(|d| d.format("%H:%M:%S").to_string())
832                    .unwrap_or_else(|| "?".to_string());
833                let side_str = match t.side {
834                    crate::market::TradeSide::Buy => "BUY",
835                    crate::market::TradeSide::Sell => "SELL",
836                };
837                let price_str = format!("{:.6}", t.price);
838                let qty_str = format!("{:.2}", t.quantity);
839                let values = [
840                    time.as_str(),
841                    side_str,
842                    price_str.as_str(),
843                    qty_str.as_str(),
844                ];
845                println!("{}", t::table_row(&cols, &values));
846            }
847
848            println!(
849                "{}",
850                t::info_row(&format!("{} trades returned", trades.len()))
851            );
852            println!("{}", t::section_footer());
853        }
854    }
855    Ok(())
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861    use crate::chains::DefaultClientFactory;
862
863    /// Helper to create a mock venue YAML pointing at the given mock server URL.
864    /// Writes a temporary venue descriptor to the user venues directory so the
865    /// registry picks it up. Returns the venue id.
866    #[allow(dead_code)]
867    fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
868        let venue_id = format!("test_mock_{}", std::process::id());
869        let yaml = format!(
870            r#"
871id: {venue_id}
872name: Test Mock Venue
873base_url: {server_url}
874timeout_secs: 5
875symbol:
876  template: "{{base}}_{{quote}}"
877  default_quote: USDT
878capabilities:
879  order_book:
880    path: /api/v1/depth
881    params:
882      symbol: "{{pair}}"
883    response:
884      asks_key: asks
885      bids_key: bids
886      level_format: positional
887  ticker:
888    path: /api/v1/ticker
889    params:
890      symbol: "{{pair}}"
891    response:
892      last_price: last
893      volume_24h: vol
894"#
895        );
896        let dir = tempfile::tempdir().unwrap();
897        let file_path = dir.path().join(format!("{}.yaml", venue_id));
898        std::fs::write(&file_path, yaml).unwrap();
899        // We can't easily inject into the registry, so instead
900        // create a ConfigurableExchangeClient directly in tests.
901        (venue_id, dir)
902    }
903
904    #[tokio::test]
905    async fn test_run_summary_with_mock_orderbook() {
906        // This test uses the DEX path since mock HTTP with the registry is complex.
907        // Tested in integration tests and exchange module unit tests instead.
908        // Test duration parsing and summary formatting here.
909        let args = SummaryArgs {
910            pair: "USDC".to_string(),
911            venue: "eth".to_string(),
912            chain: "ethereum".to_string(),
913            peg: 1.0,
914            min_levels: 1,
915            min_depth: 50.0,
916            peg_range: 0.01,
917            min_bid_ask_ratio: 0.1,
918            max_bid_ask_ratio: 10.0,
919            format: SummaryFormat::Text,
920            every: None,
921            duration: None,
922            report: None,
923            csv: None,
924        };
925
926        let factory = DefaultClientFactory {
927            chains_config: Default::default(),
928        };
929        // DEX path: will hit real DexScreener API, may fail in offline environments
930        let _result = run_summary(args, &factory).await;
931        // We don't assert success because it depends on network, just confirm no panic
932    }
933
934    #[tokio::test]
935    async fn test_run_summary_json_format() {
936        let args = SummaryArgs {
937            pair: "USDC".to_string(),
938            venue: "eth".to_string(),
939            chain: "ethereum".to_string(),
940            peg: 1.0,
941            min_levels: 1,
942            min_depth: 50.0,
943            peg_range: 0.01,
944            min_bid_ask_ratio: 0.1,
945            max_bid_ask_ratio: 10.0,
946            format: SummaryFormat::Json,
947            every: None,
948            duration: None,
949            report: None,
950            csv: None,
951        };
952
953        let factory = DefaultClientFactory {
954            chains_config: Default::default(),
955        };
956        let _result = run_summary(args, &factory).await;
957    }
958
959    #[test]
960    fn test_parse_duration_seconds() {
961        assert_eq!(parse_duration("30s").unwrap(), 30);
962        assert_eq!(parse_duration("1").unwrap(), 1);
963        assert_eq!(parse_duration("60sec").unwrap(), 60);
964    }
965
966    #[test]
967    fn test_parse_duration_minutes() {
968        assert_eq!(parse_duration("5m").unwrap(), 300);
969        assert_eq!(parse_duration("1min").unwrap(), 60);
970        assert_eq!(parse_duration("2.5m").unwrap(), 150);
971    }
972
973    #[test]
974    fn test_parse_duration_hours() {
975        assert_eq!(parse_duration("1h").unwrap(), 3600);
976        assert_eq!(parse_duration("24h").unwrap(), 86400);
977    }
978
979    #[test]
980    fn test_parse_duration_invalid() {
981        assert!(parse_duration("").is_err());
982        assert!(parse_duration("abc").is_err());
983        assert!(parse_duration("30x").is_err());
984    }
985
986    #[test]
987    fn test_parse_duration_unknown_unit_error_message() {
988        let result = parse_duration("30z");
989        assert!(result.is_err());
990        let err = result.unwrap_err();
991        assert!(err.to_string().contains("Unknown duration unit"));
992        assert!(err.to_string().contains("z"));
993    }
994
995    #[test]
996    fn test_parse_duration_invalid_number_error() {
997        let result = parse_duration("abc30s");
998        assert!(result.is_err());
999    }
1000
1001    #[test]
1002    fn test_parse_duration_non_positive() {
1003        assert!(parse_duration("0").is_err());
1004        assert!(parse_duration("-5s").is_err());
1005    }
1006
1007    // ====================================================================
1008    // base_symbol_from_pair tests
1009    // ====================================================================
1010
1011    #[test]
1012    fn test_base_symbol_from_pair_underscore() {
1013        assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1014        assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
1015    }
1016
1017    #[test]
1018    fn test_base_symbol_from_pair_lowercase_underscore() {
1019        assert_eq!(base_symbol_from_pair("dai_usdt"), "dai");
1020    }
1021
1022    #[test]
1023    fn test_base_symbol_from_pair_slash() {
1024        assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
1025    }
1026
1027    #[test]
1028    fn test_base_symbol_from_pair_concat() {
1029        assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
1030        assert_eq!(base_symbol_from_pair("DAIUSDT"), "DAI");
1031    }
1032
1033    #[test]
1034    fn test_base_symbol_from_pair_plain() {
1035        assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1036        assert_eq!(base_symbol_from_pair("ETH"), "ETH");
1037    }
1038
1039    #[test]
1040    fn test_base_symbol_from_pair_whitespace() {
1041        assert_eq!(base_symbol_from_pair("  DAI_USDT  "), "DAI");
1042    }
1043
1044    // ====================================================================
1045    // market_summary_to_markdown tests
1046    // ====================================================================
1047
1048    #[test]
1049    fn test_market_summary_to_markdown_basic() {
1050        use crate::market::{HealthCheck, MarketSummary};
1051        let summary = MarketSummary {
1052            pair: "USDCUSDT".to_string(),
1053            peg_target: 1.0,
1054            best_bid: Some(0.9999),
1055            best_ask: Some(1.0001),
1056            mid_price: Some(1.0000),
1057            spread: Some(0.0002),
1058            volume_24h: Some(1_000_000.0),
1059            bid_depth: 50_000.0,
1060            ask_depth: 50_000.0,
1061            bid_outliers: 0,
1062            ask_outliers: 0,
1063            healthy: true,
1064            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1065            execution_10k_buy: None,
1066            execution_10k_sell: None,
1067            asks: vec![],
1068            bids: vec![],
1069        };
1070        let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1071        assert!(md.contains("Market Health Report"));
1072        assert!(md.contains("USDCUSDT"));
1073        assert!(md.contains("Binance"));
1074        assert!(md.contains("Peg Target"));
1075        assert!(md.contains("1.0000"));
1076        assert!(md.contains("Healthy"));
1077    }
1078
1079    #[test]
1080    fn test_market_summary_to_markdown_no_prices() {
1081        use crate::market::{HealthCheck, MarketSummary};
1082        let summary = MarketSummary {
1083            pair: "TESTUSDT".to_string(),
1084            peg_target: 1.0,
1085            best_bid: None,
1086            best_ask: None,
1087            mid_price: None,
1088            spread: None,
1089            volume_24h: None,
1090            bid_depth: 0.0,
1091            ask_depth: 0.0,
1092            bid_outliers: 0,
1093            ask_outliers: 0,
1094            healthy: false,
1095            checks: vec![HealthCheck::Fail("No data".to_string())],
1096            execution_10k_buy: None,
1097            execution_10k_sell: None,
1098            asks: vec![],
1099            bids: vec![],
1100        };
1101        let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1102        assert!(md.contains("Market Health Report"));
1103        assert!(md.contains("-")); // missing data shown as "-"
1104    }
1105
1106    // ====================================================================
1107    // parse_duration — additional edge cases
1108    // ====================================================================
1109
1110    #[test]
1111    fn test_parse_duration_days() {
1112        assert_eq!(parse_duration("1d").unwrap(), 86400);
1113        assert_eq!(parse_duration("7d").unwrap(), 604800);
1114        assert_eq!(parse_duration("1day").unwrap(), 86400);
1115        assert_eq!(parse_duration("2days").unwrap(), 172800);
1116    }
1117
1118    #[test]
1119    fn test_parse_duration_long_names() {
1120        assert_eq!(parse_duration("30seconds").unwrap(), 30);
1121        assert_eq!(parse_duration("5minutes").unwrap(), 300);
1122        assert_eq!(parse_duration("2hours").unwrap(), 7200);
1123    }
1124
1125    #[test]
1126    fn test_parse_duration_fractional() {
1127        assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1128        assert_eq!(parse_duration("1.5m").unwrap(), 90);
1129    }
1130
1131    // ====================================================================
1132    // SummaryFormat tests
1133    // ====================================================================
1134
1135    #[test]
1136    fn test_summary_format_default() {
1137        let fmt = SummaryFormat::default();
1138        assert!(matches!(fmt, SummaryFormat::Text));
1139    }
1140
1141    #[test]
1142    fn test_summary_format_debug() {
1143        let text = format!("{:?}", SummaryFormat::Text);
1144        assert_eq!(text, "Text");
1145        let json = format!("{:?}", SummaryFormat::Json);
1146        assert_eq!(json, "Json");
1147    }
1148
1149    // ====================================================================
1150    // MarketCommands parsing tests
1151    // ====================================================================
1152
1153    #[test]
1154    fn test_ohlc_args_deserialization() {
1155        use crate::cli::{Cli, Commands};
1156        use clap::Parser;
1157        let cli = Cli::try_parse_from([
1158            "scope",
1159            "market",
1160            "ohlc",
1161            "USDC",
1162            "--venue",
1163            "binance",
1164            "--interval",
1165            "1h",
1166            "--limit",
1167            "50",
1168        ])
1169        .unwrap();
1170        if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1171            assert_eq!(args.pair, "USDC");
1172            assert_eq!(args.venue, "binance");
1173            assert_eq!(args.interval, "1h");
1174            assert_eq!(args.limit, 50);
1175        } else {
1176            panic!("Expected Market Ohlc command");
1177        }
1178    }
1179
1180    #[test]
1181    fn test_trades_args_deserialization() {
1182        use crate::cli::{Cli, Commands};
1183        use clap::Parser;
1184        let cli = Cli::try_parse_from([
1185            "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1186        ])
1187        .unwrap();
1188        if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1189            assert_eq!(args.pair, "BTC");
1190            assert_eq!(args.venue, "mexc");
1191            assert_eq!(args.limit, 100);
1192        } else {
1193            panic!("Expected Market Trades command");
1194        }
1195    }
1196
1197    #[test]
1198    fn test_base_symbol_from_pair_various_inputs() {
1199        // Additional coverage for edge cases
1200        assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1201        assert_eq!(base_symbol_from_pair("BTCUSDT"), "BTC");
1202        assert_eq!(base_symbol_from_pair("ETH/USDT"), "ETH");
1203        assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1204        assert_eq!(base_symbol_from_pair("X"), "X"); // short symbol, no USDT suffix
1205        assert_eq!(base_symbol_from_pair(""), "");
1206    }
1207
1208    #[test]
1209    fn test_summary_args_debug() {
1210        let args = SummaryArgs {
1211            pair: "USDC".to_string(),
1212            venue: "binance".to_string(),
1213            chain: "ethereum".to_string(),
1214            peg: 1.0,
1215            min_levels: 6,
1216            min_depth: 3000.0,
1217            peg_range: 0.001,
1218            min_bid_ask_ratio: 0.2,
1219            max_bid_ask_ratio: 5.0,
1220            format: SummaryFormat::Text,
1221            every: None,
1222            duration: None,
1223            report: None,
1224            csv: None,
1225        };
1226        let debug = format!("{:?}", args);
1227        assert!(debug.contains("SummaryArgs"));
1228        assert!(debug.contains("USDC"));
1229    }
1230
1231    #[test]
1232    fn test_default_constants() {
1233        assert_eq!(DEFAULT_EVERY_SECS, 60);
1234        assert_eq!(DEFAULT_DURATION_SECS, 3600);
1235    }
1236
1237    #[test]
1238    fn test_market_summary_to_markdown_with_execution_estimates() {
1239        use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1240        let summary = MarketSummary {
1241            pair: "TESTUSDT".to_string(),
1242            peg_target: 1.0,
1243            best_bid: Some(0.9999),
1244            best_ask: Some(1.0001),
1245            mid_price: Some(1.0000),
1246            spread: Some(0.0002),
1247            volume_24h: Some(1_000_000.0),
1248            bid_depth: 50_000.0,
1249            ask_depth: 50_000.0,
1250            bid_outliers: 0,
1251            ask_outliers: 0,
1252            healthy: true,
1253            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1254            execution_10k_buy: Some(ExecutionEstimate {
1255                notional_usdt: 10_000.0,
1256                side: ExecutionSide::Buy,
1257                vwap: 1.0001,
1258                slippage_bps: 1.5,
1259                fillable: true,
1260            }),
1261            execution_10k_sell: Some(ExecutionEstimate {
1262                notional_usdt: 10_000.0,
1263                side: ExecutionSide::Sell,
1264                vwap: 0.0,
1265                slippage_bps: 0.0,
1266                fillable: false,
1267            }),
1268            asks: vec![],
1269            bids: vec![],
1270        };
1271        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1272        assert!(md.contains("Market Health Report"));
1273        assert!(md.contains("TESTUSDT"));
1274        assert!(md.contains("TestVenue"));
1275        // Check for fillable buy slippage (should show "1.50 bps")
1276        assert!(md.contains("1.50 bps"));
1277        // Check for unfillable sell (should show "insufficient")
1278        assert!(md.contains("insufficient"));
1279    }
1280
1281    #[tokio::test]
1282    async fn test_run_with_summary_command() {
1283        // Test the run() dispatcher with a DEX venue (doesn't require mock HTTP)
1284        let args = MarketCommands::Summary(SummaryArgs {
1285            pair: "USDC".to_string(),
1286            venue: "eth".to_string(),
1287            chain: "ethereum".to_string(),
1288            peg: 1.0,
1289            min_levels: 1,
1290            min_depth: 50.0,
1291            peg_range: 0.01,
1292            min_bid_ask_ratio: 0.1,
1293            max_bid_ask_ratio: 10.0,
1294            format: SummaryFormat::Text,
1295            every: None,
1296            duration: None,
1297            report: None,
1298            csv: None,
1299        });
1300
1301        let factory = DefaultClientFactory {
1302            chains_config: Default::default(),
1303        };
1304        let config = Config::default();
1305        let _result = run(args, &config, &factory).await;
1306        // Don't assert success - depends on network
1307    }
1308
1309    #[test]
1310    fn test_is_dex_venue() {
1311        assert!(is_dex_venue("eth"));
1312        assert!(is_dex_venue("ethereum"));
1313        assert!(is_dex_venue("Ethereum"));
1314        assert!(is_dex_venue("solana"));
1315        assert!(is_dex_venue("Solana"));
1316        assert!(!is_dex_venue("binance"));
1317        assert!(!is_dex_venue("okx"));
1318        assert!(!is_dex_venue("mexc"));
1319    }
1320
1321    #[test]
1322    fn test_dex_venue_to_chain() {
1323        assert_eq!(dex_venue_to_chain("eth"), "ethereum");
1324        assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
1325        assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
1326        assert_eq!(dex_venue_to_chain("solana"), "solana");
1327    }
1328
1329    #[test]
1330    fn test_venue_registry_loaded_in_cex_path() {
1331        // Verify the registry loads and can create an exchange client for any built-in venue
1332        let registry = VenueRegistry::load().unwrap();
1333        assert!(registry.contains("binance"));
1334        let client = registry.create_exchange_client("binance");
1335        assert!(client.is_ok());
1336    }
1337
1338    #[test]
1339    fn test_venue_registry_error_for_unknown() {
1340        let registry = VenueRegistry::load().unwrap();
1341        let result = registry.create_exchange_client("kracken");
1342        assert!(result.is_err());
1343        let err = result.unwrap_err().to_string();
1344        assert!(err.contains("Unknown venue"));
1345        assert!(err.contains("Did you mean")); // should suggest kraken (distance 1)
1346    }
1347
1348    #[tokio::test]
1349    async fn test_run_summary_json_format_with_mock() {
1350        let args = SummaryArgs {
1351            pair: "USDC".to_string(),
1352            venue: "eth".to_string(),
1353            chain: "ethereum".to_string(),
1354            peg: 1.0,
1355            min_levels: 1,
1356            min_depth: 50.0,
1357            peg_range: 0.01,
1358            min_bid_ask_ratio: 0.1,
1359            max_bid_ask_ratio: 10.0,
1360            format: SummaryFormat::Json,
1361            every: None,
1362            duration: None,
1363            report: None,
1364            csv: None,
1365        };
1366
1367        let factory = DefaultClientFactory {
1368            chains_config: Default::default(),
1369        };
1370        let _result = run_summary(args, &factory).await;
1371    }
1372
1373    // ====================================================================
1374    // OHLC command tests
1375    // ====================================================================
1376
1377    #[test]
1378    fn test_ohlc_format_default() {
1379        let fmt: OhlcFormat = Default::default();
1380        assert_eq!(fmt, OhlcFormat::Text);
1381    }
1382
1383    #[test]
1384    fn test_ohlc_format_display() {
1385        // ValueEnum-derived parsing
1386        assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1387        assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1388    }
1389
1390    #[test]
1391    fn test_ohlc_args_default_values() {
1392        // Verify we can construct OhlcArgs with defaults
1393        let args = OhlcArgs {
1394            pair: "BTC".to_string(),
1395            venue: "binance".to_string(),
1396            interval: "1h".to_string(),
1397            limit: 100,
1398            format: OhlcFormat::Text,
1399        };
1400        assert_eq!(args.pair, "BTC");
1401        assert_eq!(args.venue, "binance");
1402        assert_eq!(args.interval, "1h");
1403        assert_eq!(args.limit, 100);
1404    }
1405
1406    #[test]
1407    fn test_trades_args_construction() {
1408        let args = TradesArgs {
1409            pair: "ETH".to_string(),
1410            venue: "okx".to_string(),
1411            limit: 50,
1412            format: OhlcFormat::Json,
1413        };
1414        assert_eq!(args.pair, "ETH");
1415        assert_eq!(args.venue, "okx");
1416        assert_eq!(args.limit, 50);
1417    }
1418
1419    #[tokio::test]
1420    async fn test_run_ohlc_unknown_venue() {
1421        let args = OhlcArgs {
1422            pair: "BTC".to_string(),
1423            venue: "nonexistent_venue".to_string(),
1424            interval: "1h".to_string(),
1425            limit: 10,
1426            format: OhlcFormat::Text,
1427        };
1428        let result = run_ohlc(args).await;
1429        assert!(result.is_err());
1430        let err = result.unwrap_err().to_string();
1431        assert!(
1432            err.contains("not found"),
1433            "expected 'not found' error, got: {}",
1434            err
1435        );
1436    }
1437
1438    #[tokio::test]
1439    async fn test_run_trades_unknown_venue() {
1440        let args = TradesArgs {
1441            pair: "BTC".to_string(),
1442            venue: "nonexistent_venue".to_string(),
1443            limit: 10,
1444            format: OhlcFormat::Text,
1445        };
1446        let result = run_trades(args).await;
1447        assert!(result.is_err());
1448        let err = result.unwrap_err().to_string();
1449        assert!(
1450            err.contains("not found"),
1451            "expected 'not found' error, got: {}",
1452            err
1453        );
1454    }
1455
1456    #[tokio::test]
1457    async fn test_run_dispatches_ohlc() {
1458        let cmd = MarketCommands::Ohlc(OhlcArgs {
1459            pair: "BTC".to_string(),
1460            venue: "nonexistent_test_venue".to_string(),
1461            interval: "1h".to_string(),
1462            limit: 5,
1463            format: OhlcFormat::Text,
1464        });
1465        let factory = DefaultClientFactory {
1466            chains_config: Default::default(),
1467        };
1468        let config = Config::default();
1469        let result = run(cmd, &config, &factory).await;
1470        // Should error with venue not found
1471        assert!(result.is_err());
1472    }
1473
1474    #[tokio::test]
1475    async fn test_run_dispatches_trades() {
1476        let cmd = MarketCommands::Trades(TradesArgs {
1477            pair: "ETH".to_string(),
1478            venue: "nonexistent_test_venue".to_string(),
1479            limit: 5,
1480            format: OhlcFormat::Json,
1481        });
1482        let factory = DefaultClientFactory {
1483            chains_config: Default::default(),
1484        };
1485        let config = Config::default();
1486        let result = run(cmd, &config, &factory).await;
1487        assert!(result.is_err());
1488    }
1489
1490    #[tokio::test]
1491    async fn test_run_ohlc_text_format_with_real_venue() {
1492        // Uses a real venue with a real API call. May succeed or fail depending
1493        // on network availability. Exercises venue resolution and client creation.
1494        let args = OhlcArgs {
1495            pair: "BTC".to_string(),
1496            venue: "binance".to_string(),
1497            interval: "1h".to_string(),
1498            limit: 3,
1499            format: OhlcFormat::Text,
1500        };
1501        let _result = run_ohlc(args).await;
1502        // Don't assert success — depends on network
1503    }
1504
1505    #[tokio::test]
1506    async fn test_run_ohlc_json_format_with_real_venue() {
1507        let args = OhlcArgs {
1508            pair: "ETH".to_string(),
1509            venue: "binance".to_string(),
1510            interval: "15m".to_string(),
1511            limit: 2,
1512            format: OhlcFormat::Json,
1513        };
1514        let _result = run_ohlc(args).await;
1515    }
1516
1517    #[tokio::test]
1518    async fn test_run_trades_text_format_with_real_venue() {
1519        let args = TradesArgs {
1520            pair: "BTC".to_string(),
1521            venue: "binance".to_string(),
1522            limit: 5,
1523            format: OhlcFormat::Text,
1524        };
1525        let _result = run_trades(args).await;
1526    }
1527
1528    #[tokio::test]
1529    async fn test_run_trades_json_format_with_real_venue() {
1530        let args = TradesArgs {
1531            pair: "ETH".to_string(),
1532            venue: "binance".to_string(),
1533            limit: 3,
1534            format: OhlcFormat::Json,
1535        };
1536        let _result = run_trades(args).await;
1537    }
1538
1539    #[tokio::test]
1540    async fn test_run_ohlc_multiple_venues() {
1541        // Exercise venue resolution for several built-in venues
1542        for venue in &["mexc", "okx", "bybit"] {
1543            let args = OhlcArgs {
1544                pair: "BTC".to_string(),
1545                venue: venue.to_string(),
1546                interval: "1h".to_string(),
1547                limit: 2,
1548                format: OhlcFormat::Json,
1549            };
1550            let _result = run_ohlc(args).await;
1551        }
1552    }
1553
1554    #[tokio::test]
1555    async fn test_run_trades_multiple_venues() {
1556        for venue in &["mexc", "okx", "bybit"] {
1557            let args = TradesArgs {
1558                pair: "BTC".to_string(),
1559                venue: venue.to_string(),
1560                limit: 3,
1561                format: OhlcFormat::Text,
1562            };
1563            let _result = run_trades(args).await;
1564        }
1565    }
1566
1567    // ====================================================================
1568    // parse_duration — additional edge cases for full coverage
1569    // ====================================================================
1570
1571    #[test]
1572    fn test_parse_duration_whitespace_empty() {
1573        assert!(parse_duration("").is_err());
1574        assert!(parse_duration("   ").is_err());
1575    }
1576
1577    #[test]
1578    fn test_parse_duration_sec_secs_second_seconds() {
1579        assert_eq!(parse_duration("1sec").unwrap(), 1);
1580        assert_eq!(parse_duration("2secs").unwrap(), 2);
1581        assert_eq!(parse_duration("1second").unwrap(), 1);
1582        assert_eq!(parse_duration("3seconds").unwrap(), 3);
1583    }
1584
1585    #[test]
1586    fn test_parse_duration_minute_minutes() {
1587        assert_eq!(parse_duration("1minute").unwrap(), 60);
1588        assert_eq!(parse_duration("2minutes").unwrap(), 120);
1589    }
1590
1591    #[test]
1592    fn test_parse_duration_hr_hrs_hour_hours() {
1593        assert_eq!(parse_duration("1hr").unwrap(), 3600);
1594        assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1595        assert_eq!(parse_duration("1hour").unwrap(), 3600);
1596        assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1597    }
1598
1599    #[test]
1600    fn test_parse_duration_number_only_defaults_to_seconds() {
1601        assert_eq!(parse_duration("1.5").unwrap(), 1);
1602        assert_eq!(parse_duration("42").unwrap(), 42);
1603    }
1604
1605    #[test]
1606    fn test_parse_duration_trimmed_input() {
1607        assert_eq!(parse_duration("  30s  ").unwrap(), 30);
1608        assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1609    }
1610
1611    #[test]
1612    fn test_parse_duration_invalid_number_format() {
1613        assert!(parse_duration("1.2.3s").is_err());
1614        assert!(parse_duration("abc").is_err());
1615    }
1616
1617    // ====================================================================
1618    // dex_venue_to_chain — unknown venue fallback
1619    // ====================================================================
1620
1621    #[test]
1622    fn test_dex_venue_to_chain_unknown_returns_ethereum() {
1623        assert_eq!(dex_venue_to_chain("binance"), "ethereum");
1624        assert_eq!(dex_venue_to_chain("kraken"), "ethereum");
1625        assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
1626    }
1627
1628    // ====================================================================
1629    // market_summary_to_markdown — mixed Pass/Fail checks
1630    // ====================================================================
1631
1632    #[test]
1633    fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1634        use crate::market::{HealthCheck, MarketSummary};
1635        let summary = MarketSummary {
1636            pair: "TESTUSDT".to_string(),
1637            peg_target: 1.0,
1638            best_bid: Some(0.9999),
1639            best_ask: Some(1.0001),
1640            mid_price: Some(1.0000),
1641            spread: Some(0.0002),
1642            volume_24h: Some(500_000.0),
1643            bid_depth: 40_000.0,
1644            ask_depth: 45_000.0,
1645            bid_outliers: 0,
1646            ask_outliers: 0,
1647            healthy: false,
1648            checks: vec![
1649                HealthCheck::Pass("Spread within range".to_string()),
1650                HealthCheck::Fail("Bid depth below minimum".to_string()),
1651            ],
1652            execution_10k_buy: None,
1653            execution_10k_sell: None,
1654            asks: vec![],
1655            bids: vec![],
1656        };
1657        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1658        assert!(md.contains("✓ Spread within range"));
1659        assert!(md.contains("✗ Bid depth below minimum"));
1660        assert!(md.contains("✗")); // unhealthy indicator in table
1661    }
1662
1663    #[test]
1664    fn test_market_summary_to_markdown_empty_checks() {
1665        use crate::market::MarketSummary;
1666        let summary = MarketSummary {
1667            pair: "X".to_string(),
1668            peg_target: 1.0,
1669            best_bid: Some(1.0),
1670            best_ask: Some(1.0),
1671            mid_price: Some(1.0),
1672            spread: Some(0.0),
1673            volume_24h: None,
1674            bid_depth: 100.0,
1675            ask_depth: 100.0,
1676            bid_outliers: 0,
1677            ask_outliers: 0,
1678            healthy: true,
1679            checks: vec![],
1680            execution_10k_buy: None,
1681            execution_10k_sell: None,
1682            asks: vec![],
1683            bids: vec![],
1684        };
1685        let md = market_summary_to_markdown(&summary, "Venue", "X");
1686        assert!(md.contains("Market Health Report"));
1687        assert!(md.contains("Health Checks"));
1688    }
1689
1690    // ====================================================================
1691    // OhlcFormat / SummaryFormat trait coverage
1692    // ====================================================================
1693
1694    #[test]
1695    fn test_ohlc_format_partial_eq() {
1696        assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1697        assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1698        assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1699    }
1700
1701    #[test]
1702    fn test_summary_format_clone_copy() {
1703        let text = SummaryFormat::Text;
1704        let cloned = text;
1705        assert!(matches!(cloned, SummaryFormat::Text));
1706        assert!(matches!(text, SummaryFormat::Text));
1707    }
1708
1709    // ====================================================================
1710    // run_summary — error paths and report/csv
1711    // ====================================================================
1712
1713    #[tokio::test]
1714    async fn test_run_summary_interval_zero_error() {
1715        let args = SummaryArgs {
1716            pair: "USDC".to_string(),
1717            venue: "eth".to_string(),
1718            chain: "ethereum".to_string(),
1719            peg: 1.0,
1720            min_levels: 1,
1721            min_depth: 50.0,
1722            peg_range: 0.01,
1723            min_bid_ask_ratio: 0.1,
1724            max_bid_ask_ratio: 10.0,
1725            format: SummaryFormat::Text,
1726            every: Some("0.1s".to_string()),
1727            duration: Some("1m".to_string()),
1728            report: None,
1729            csv: None,
1730        };
1731        let factory = DefaultClientFactory {
1732            chains_config: Default::default(),
1733        };
1734        let result = run_summary(args, &factory).await;
1735        assert!(result.is_err());
1736        let err = result.unwrap_err().to_string();
1737        assert!(
1738            err.contains("Interval must be positive") || err.contains("positive"),
1739            "expected interval error, got: {}",
1740            err
1741        );
1742    }
1743
1744    #[tokio::test]
1745    async fn test_run_summary_one_shot_with_report() {
1746        let report_dir = tempfile::tempdir().unwrap();
1747        let report_path = report_dir.path().join("report.md");
1748        let args = SummaryArgs {
1749            pair: "USDC".to_string(),
1750            venue: "eth".to_string(),
1751            chain: "ethereum".to_string(),
1752            peg: 1.0,
1753            min_levels: 1,
1754            min_depth: 50.0,
1755            peg_range: 0.01,
1756            min_bid_ask_ratio: 0.1,
1757            max_bid_ask_ratio: 10.0,
1758            format: SummaryFormat::Text,
1759            every: None,
1760            duration: None,
1761            report: Some(report_path.clone()),
1762            csv: None,
1763        };
1764        let factory = DefaultClientFactory {
1765            chains_config: Default::default(),
1766        };
1767        let result = run_summary(args, &factory).await;
1768        if result.is_ok() {
1769            let content = std::fs::read_to_string(&report_path).unwrap();
1770            assert!(content.contains("Market Health Report"));
1771        }
1772    }
1773
1774    // ====================================================================
1775    // MarketCommands and struct Debug/construction
1776    // ====================================================================
1777
1778    #[test]
1779    fn test_market_commands_debug() {
1780        let cmd = MarketCommands::Summary(SummaryArgs {
1781            pair: "USDC".to_string(),
1782            venue: "binance".to_string(),
1783            chain: "ethereum".to_string(),
1784            peg: 1.0,
1785            min_levels: 6,
1786            min_depth: 3000.0,
1787            peg_range: 0.001,
1788            min_bid_ask_ratio: 0.2,
1789            max_bid_ask_ratio: 5.0,
1790            format: SummaryFormat::Text,
1791            every: None,
1792            duration: None,
1793            report: None,
1794            csv: None,
1795        });
1796        let debug = format!("{:?}", cmd);
1797        assert!(debug.contains("Summary"));
1798
1799        let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
1800            pair: "BTC".to_string(),
1801            venue: "binance".to_string(),
1802            interval: "1h".to_string(),
1803            limit: 100,
1804            format: OhlcFormat::Text,
1805        });
1806        assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
1807
1808        let trades_cmd = MarketCommands::Trades(TradesArgs {
1809            pair: "ETH".to_string(),
1810            venue: "binance".to_string(),
1811            limit: 50,
1812            format: OhlcFormat::Json,
1813        });
1814        assert!(format!("{:?}", trades_cmd).contains("Trades"));
1815    }
1816
1817    #[test]
1818    fn test_summary_args_with_report_csv_options() {
1819        let args = SummaryArgs {
1820            pair: "DAI".to_string(),
1821            venue: "binance".to_string(),
1822            chain: "ethereum".to_string(),
1823            peg: 1.0,
1824            min_levels: 6,
1825            min_depth: 3000.0,
1826            peg_range: 0.001,
1827            min_bid_ask_ratio: 0.2,
1828            max_bid_ask_ratio: 5.0,
1829            format: SummaryFormat::Json,
1830            every: Some("30s".to_string()),
1831            duration: Some("1h".to_string()),
1832            report: Some(std::path::PathBuf::from("/tmp/report.md")),
1833            csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
1834        };
1835        assert_eq!(args.pair, "DAI");
1836        assert_eq!(args.venue, "binance");
1837        assert!(args.every.is_some());
1838        assert!(args.duration.is_some());
1839        assert!(args.report.is_some());
1840        assert!(args.csv.is_some());
1841    }
1842
1843    #[test]
1844    fn test_base_symbol_from_pair_4char_usdt() {
1845        // "USDT" itself has len 4, so doesn't satisfy p.len() > 4
1846        assert_eq!(base_symbol_from_pair("USDT"), "USDT");
1847    }
1848
1849    #[test]
1850    fn test_market_summary_to_markdown_unhealthy() {
1851        use crate::market::{HealthCheck, MarketSummary};
1852        let summary = MarketSummary {
1853            pair: "X".to_string(),
1854            peg_target: 1.0,
1855            best_bid: Some(0.99),
1856            best_ask: Some(1.01),
1857            mid_price: Some(1.0),
1858            spread: Some(0.02),
1859            volume_24h: None,
1860            bid_depth: 100.0,
1861            ask_depth: 100.0,
1862            bid_outliers: 0,
1863            ask_outliers: 0,
1864            healthy: false,
1865            checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
1866            execution_10k_buy: None,
1867            execution_10k_sell: None,
1868            asks: vec![],
1869            bids: vec![],
1870        };
1871        let md = market_summary_to_markdown(&summary, "Test", "X");
1872        assert!(md.contains("✗"));
1873        assert!(md.contains("Peg deviation too high"));
1874    }
1875
1876    #[test]
1877    fn test_ohlc_format_eq() {
1878        assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1879        assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1880    }
1881
1882    #[test]
1883    fn test_trades_args_default_venue() {
1884        let args = TradesArgs {
1885            pair: "USDC".to_string(),
1886            venue: "binance".to_string(),
1887            limit: 50,
1888            format: OhlcFormat::Text,
1889        };
1890        assert_eq!(args.pair, "USDC");
1891        assert_eq!(args.venue, "binance");
1892    }
1893}