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