Skip to main content

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