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