Skip to main content

scope/cli/
market.rs

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