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            tokens: tokens.clone(),
584        });
585        return write_report(&args, &summary, out);
586    };
587
588    note!(
589        out,
590        "Running market summary every {}s {} (Ctrl+C to stop)\n",
591        schedule.every.as_secs(),
592        match schedule.duration {
593            Some(d) => format!("for {}s", d.as_secs()),
594            None => "until cancelled".to_string(),
595        }
596    );
597    let metrics = series::summary_metrics(&thresholds);
598    let last = repeat::run(&schedule, &metrics, args.csv.as_deref(), out, |n| {
599        let (args, thresholds, metrics, tokens) = (&args, &thresholds, &metrics, &tokens);
600        async move {
601            let (summary, report) =
602                summary_run(args, factory, thresholds, tokens, Some(n), out).await?;
603            Ok(RunOutput {
604                sample: series::sample(&summary, metrics),
605                snapshot: report,
606                view: Some(SummaryView {
607                    summary: summary.clone(),
608                    venue: args.venue.clone(),
609                    tokens: tokens.to_vec(),
610                }),
611                value: summary,
612            })
613        }
614    })
615    .await?;
616
617    if let Some(summary) = last {
618        write_report(&args, &summary, out)?;
619    }
620    if let Some(ref csv_path) = args.csv {
621        out.note_saved("Time-series CSV", csv_path.display());
622    }
623    Ok(())
624}
625
626// =============================================================================
627// OHLC Command
628// =============================================================================
629
630/// Execute the `scope market ohlc` command.
631async fn run_ohlc(args: OhlcArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
632    let json = matches!(args.format, OhlcFormat::Json);
633    check_venue_token(
634        &args.venue,
635        &args.pair,
636        None,
637        args.token_contract.clone(),
638        json,
639        http.clone(),
640        out,
641    )
642    .await?;
643    let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
644    run_ohlc_with(&client, args, out).await
645}
646
647/// `scope market ohlc` on `client`, the venue that `args.venue` names.
648async fn run_ohlc_with(client: &ExchangeClient, args: OhlcArgs, out: &Output) -> Result<()> {
649    let pair = client.venue_symbol(&Pair::parse(&args.pair));
650
651    let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
652
653    match args.format {
654        OhlcFormat::Json => {
655            let json_str = serde_json::to_string_pretty(&candles)
656                .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
657            outln!(out, "{json_str}")?;
658        }
659        OhlcFormat::Text => {
660            outln!(
661                out,
662                "{}",
663                t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
664            )?;
665            outln!(out, "{}", t::kv_row("Interval", &args.interval))?;
666            outln!(out, "{}", t::kv_row("Limit", &args.limit.to_string()))?;
667
668            let cols = [
669                t::Col {
670                    label: "Open Time",
671                    width: 19,
672                    align: '>',
673                },
674                t::Col {
675                    label: "Open",
676                    width: 12,
677                    align: '>',
678                },
679                t::Col {
680                    label: "High",
681                    width: 12,
682                    align: '>',
683                },
684                t::Col {
685                    label: "Low",
686                    width: 12,
687                    align: '>',
688                },
689                t::Col {
690                    label: "Close",
691                    width: 12,
692                    align: '>',
693                },
694                t::Col {
695                    label: "Volume",
696                    width: 14,
697                    align: '>',
698                },
699            ];
700            outln!(out, "{}", t::table_header(&cols))?;
701
702            for c in &candles {
703                let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
704                    .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
705                    .unwrap_or_else(|| format!("{}", c.open_time));
706                let open_str = format!("{:.6}", c.open);
707                let high_str = format!("{:.6}", c.high);
708                let low_str = format!("{:.6}", c.low);
709                let close_str = format!("{:.6}", c.close);
710                let volume_str = format!("{:.2}", c.volume);
711                let values = [
712                    dt.as_str(),
713                    open_str.as_str(),
714                    high_str.as_str(),
715                    low_str.as_str(),
716                    close_str.as_str(),
717                    volume_str.as_str(),
718                ];
719                outln!(out, "{}", t::table_row(&cols, &values))?;
720            }
721
722            outln!(
723                out,
724                "{}",
725                t::info_row(&format!("{} candles returned", candles.len()))
726            )?;
727            outln!(out, "{}", t::section_footer())?;
728        }
729    }
730    Ok(())
731}
732
733// =============================================================================
734// Trades Command
735// =============================================================================
736
737/// Execute the `scope market trades` command.
738async fn run_trades(args: TradesArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
739    let json = matches!(args.format, OhlcFormat::Json);
740    check_venue_token(
741        &args.venue,
742        &args.pair,
743        None,
744        args.token_contract.clone(),
745        json,
746        http.clone(),
747        out,
748    )
749    .await?;
750    let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
751    run_trades_with(&client, args, out).await
752}
753
754/// `scope market trades` on `client`, the venue that `args.venue` names.
755async fn run_trades_with(client: &ExchangeClient, args: TradesArgs, out: &Output) -> Result<()> {
756    let pair = client.venue_symbol(&Pair::parse(&args.pair));
757
758    let trades = client.fetch_recent_trades(&pair, args.limit).await?;
759
760    match args.format {
761        OhlcFormat::Json => {
762            let json_str = serde_json::to_string_pretty(&trades)
763                .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
764            outln!(out, "{json_str}")?;
765        }
766        OhlcFormat::Text => {
767            outln!(
768                out,
769                "{}",
770                t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
771            )?;
772
773            let cols = [
774                t::Col {
775                    label: "Time",
776                    width: 10,
777                    align: '>',
778                },
779                t::Col {
780                    label: "Side",
781                    width: 5,
782                    align: '>',
783                },
784                t::Col {
785                    label: "Price",
786                    width: 12,
787                    align: '>',
788                },
789                t::Col {
790                    label: "Qty",
791                    width: 12,
792                    align: '>',
793                },
794            ];
795            outln!(out, "{}", t::table_header(&cols))?;
796
797            for t in &trades {
798                let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
799                    .map(|d| d.format("%H:%M:%S").to_string())
800                    .unwrap_or_else(|| "?".to_string());
801                let side_str = match t.side {
802                    scope::market::TradeSide::Buy => "BUY",
803                    scope::market::TradeSide::Sell => "SELL",
804                };
805                let price_str = format!("{:.6}", t.price);
806                let qty_str = format!("{:.2}", t.quantity);
807                let values = [
808                    time.as_str(),
809                    side_str,
810                    price_str.as_str(),
811                    qty_str.as_str(),
812                ];
813                outln!(out, "{}", t::table_row(&cols, &values))?;
814            }
815
816            outln!(
817                out,
818                "{}",
819                t::info_row(&format!("{} trades returned", trades.len()))
820            )?;
821            outln!(out, "{}", t::section_footer())?;
822        }
823    }
824    Ok(())
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use scope::chains::DefaultClientFactory;
831    use scope::market::DexVenue;
832
833    fn quiet() -> crate::cli::output::Output {
834        crate::cli::output::Output::capture().0
835    }
836
837    /// Helper to create a mock venue YAML pointing at the given mock server URL.
838    /// Writes a temporary venue descriptor to the user venues directory so the
839    /// registry picks it up. Returns the venue id.
840    #[allow(dead_code)]
841    fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
842        let venue_id = format!("test_mock_{}", std::process::id());
843        let yaml = format!(
844            r#"
845id: {venue_id}
846name: Test Mock Venue
847base_url: {server_url}
848timeout_secs: 5
849symbol:
850  template: "{{base}}_{{quote}}"
851  default_quote: USDT
852capabilities:
853  order_book:
854    path: /api/v1/depth
855    params:
856      symbol: "{{pair}}"
857    response:
858      asks_key: asks
859      bids_key: bids
860      level_format: positional
861  ticker:
862    path: /api/v1/ticker
863    params:
864      symbol: "{{pair}}"
865    response:
866      last_price: last
867      volume_24h: vol
868"#
869        );
870        let dir = tempfile::tempdir().unwrap();
871        let file_path = dir.path().join(format!("{}.yaml", venue_id));
872        std::fs::write(&file_path, yaml).unwrap();
873        // We can't easily inject into the registry, so instead
874        // create a ConfigurableExchangeClient directly in tests.
875        (venue_id, dir)
876    }
877
878    #[tokio::test]
879    async fn test_run_summary_with_mock_orderbook() {
880        // This test uses the DEX path since mock HTTP with the registry is complex.
881        // Tested in integration tests and exchange module unit tests instead.
882        // Test duration parsing and summary formatting here.
883        let args = SummaryArgs {
884            pair: "USDC".to_string(),
885            venue: "eth".to_string(),
886            token_contract: None,
887            chain: None,
888            health: HealthArgs {
889                peg_target: Some(1.0),
890                peg_range: Some(0.01),
891                min_levels: Some(1),
892                min_depth: Some(50.0),
893                min_bid_ask_ratio: Some(0.1),
894                max_bid_ask_ratio: Some(10.0),
895                ..Default::default()
896            },
897            format: SummaryFormat::Text,
898            every: None,
899            duration: None,
900            report: None,
901            csv: None,
902        };
903
904        let http: std::sync::Arc<dyn scope::http::HttpClient> =
905            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
906        let factory = DefaultClientFactory {
907            chains_config: Default::default(),
908            http,
909        };
910        // DEX path: will hit real DexScreener API, may fail in offline environments
911        let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
912        // We don't assert success because it depends on network, just confirm no panic
913    }
914
915    #[tokio::test]
916    async fn test_run_summary_json_format() {
917        let args = SummaryArgs {
918            pair: "USDC".to_string(),
919            venue: "eth".to_string(),
920            token_contract: None,
921            chain: None,
922            health: HealthArgs {
923                peg_target: Some(1.0),
924                peg_range: Some(0.01),
925                min_levels: Some(1),
926                min_depth: Some(50.0),
927                min_bid_ask_ratio: Some(0.1),
928                max_bid_ask_ratio: Some(10.0),
929                ..Default::default()
930            },
931            format: SummaryFormat::Json,
932            every: None,
933            duration: None,
934            report: None,
935            csv: None,
936        };
937
938        let http: std::sync::Arc<dyn scope::http::HttpClient> =
939            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
940        let factory = DefaultClientFactory {
941            chains_config: Default::default(),
942            http,
943        };
944        let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
945    }
946
947    #[test]
948    fn test_duration_forever_has_no_end_and_the_default_stays_one_hour() {
949        // #96: `--duration forever` (or `inf`) runs until cancelled; leaving
950        // the flag out keeps the 1 h default so scripts do not change.
951        let args = |duration: Option<&str>| SummaryArgs {
952            pair: "USDC".to_string(),
953            venue: "binance".to_string(),
954            token_contract: None,
955            chain: None,
956            health: HealthArgs::default(),
957            format: SummaryFormat::Text,
958            every: Some("30s".to_string()),
959            duration: duration.map(str::to_string),
960            report: None,
961            csv: None,
962        };
963        for word in ["forever", "inf", "FOREVER"] {
964            let s = summary_schedule(&args(Some(word))).unwrap().unwrap();
965            assert_eq!(s.duration, None, "{word}");
966        }
967        let s = summary_schedule(&args(None)).unwrap().unwrap();
968        assert_eq!(s.duration, Some(Duration::from_secs(DEFAULT_DURATION_SECS)));
969        let s = summary_schedule(&args(Some("10m"))).unwrap().unwrap();
970        assert_eq!(s.duration, Some(Duration::from_secs(600)));
971    }
972
973    #[test]
974    fn test_parse_duration_seconds() {
975        assert_eq!(parse_duration("30s").unwrap(), 30);
976        assert!(
977            parse_duration("forever").is_err(),
978            "not a span; --duration only"
979        );
980        assert_eq!(parse_duration("1").unwrap(), 1);
981        assert_eq!(parse_duration("60sec").unwrap(), 60);
982    }
983
984    #[test]
985    fn test_parse_duration_minutes() {
986        assert_eq!(parse_duration("5m").unwrap(), 300);
987        assert_eq!(parse_duration("1min").unwrap(), 60);
988        assert_eq!(parse_duration("2.5m").unwrap(), 150);
989    }
990
991    #[test]
992    fn test_parse_duration_hours() {
993        assert_eq!(parse_duration("1h").unwrap(), 3600);
994        assert_eq!(parse_duration("24h").unwrap(), 86400);
995    }
996
997    #[test]
998    fn test_parse_duration_invalid() {
999        assert!(parse_duration("").is_err());
1000        assert!(parse_duration("abc").is_err());
1001        assert!(parse_duration("30x").is_err());
1002    }
1003
1004    #[test]
1005    fn test_parse_duration_unknown_unit_error_message() {
1006        let result = parse_duration("30z");
1007        assert!(result.is_err());
1008        let err = result.unwrap_err();
1009        assert!(err.to_string().contains("Unknown duration unit"));
1010        assert!(err.to_string().contains("z"));
1011    }
1012
1013    #[test]
1014    fn test_parse_duration_invalid_number_error() {
1015        let result = parse_duration("abc30s");
1016        assert!(result.is_err());
1017    }
1018
1019    #[test]
1020    fn test_parse_duration_non_positive() {
1021        assert!(parse_duration("0").is_err());
1022        assert!(parse_duration("-5s").is_err());
1023    }
1024
1025    // ====================================================================
1026    // base_symbol_from_pair tests
1027    // ====================================================================
1028
1029    #[test]
1030    fn test_base_symbol_from_pair_underscore() {
1031        assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1032        assert_eq!(Pair::parse("USDC_USDT").base, "USDC");
1033    }
1034
1035    #[test]
1036    fn test_base_symbol_from_pair_lowercase_underscore() {
1037        assert_eq!(Pair::parse("dai_usdt").base, "dai");
1038    }
1039
1040    #[test]
1041    fn test_base_symbol_from_pair_slash() {
1042        assert_eq!(Pair::parse("USDC/USDT").base, "USDC");
1043    }
1044
1045    #[test]
1046    fn test_base_symbol_from_pair_concat() {
1047        assert_eq!(Pair::parse("USDCUSDT").base, "USDC");
1048        assert_eq!(Pair::parse("DAIUSDT").base, "DAI");
1049    }
1050
1051    #[test]
1052    fn test_base_symbol_from_pair_plain() {
1053        assert_eq!(Pair::parse("USDC").base, "USDC");
1054        assert_eq!(Pair::parse("ETH").base, "ETH");
1055    }
1056
1057    #[test]
1058    fn test_base_symbol_from_pair_whitespace() {
1059        assert_eq!(Pair::parse("  DAI_USDT  ").base, "DAI");
1060    }
1061
1062    #[test]
1063    fn test_normalize_pair_input_key_value() {
1064        assert_eq!(Pair::parse("pair=USDT_PUSD").to_string(), "USDT/PUSD");
1065        assert_eq!(
1066            Pair::parse("pair_symbol=USDT_PUSD").to_string(),
1067            "USDT/PUSD"
1068        );
1069        assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1070    }
1071
1072    #[test]
1073    fn test_parse_pair_components_delimited() {
1074        assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1075        assert_eq!(Pair::parse("ETH/USDC").to_string(), "ETH/USDC");
1076        assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
1077    }
1078
1079    #[test]
1080    fn test_parse_pair_components_concat_common_quote() {
1081        assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
1082        assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
1083        assert_eq!(Pair::parse("ARB").quote, None);
1084    }
1085
1086    // ====================================================================
1087    // market_summary_to_markdown tests
1088    // ====================================================================
1089
1090    #[test]
1091    fn test_market_summary_to_markdown_basic() {
1092        use scope::market::{HealthCheck, MarketSummary};
1093        let summary = MarketSummary {
1094            pair: "USDCUSDT".to_string(),
1095            source: scope::market::BookSource::Exchange,
1096            peg_target: 1.0,
1097            peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1098            best_bid: Some(0.9999),
1099            best_ask: Some(1.0001),
1100            mid_price: Some(1.0000),
1101            spread: Some(0.0002),
1102            spread_pct: None,
1103            top_depth: Default::default(),
1104            volume_24h: Some(1_000_000.0),
1105            bid_depth: 50_000.0,
1106            ask_depth: 50_000.0,
1107            bid_outliers: 0,
1108            ask_outliers: 0,
1109            healthy: true,
1110            bot_patterns: None,
1111            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1112            execution_10k_buy: None,
1113            execution_10k_sell: None,
1114            asks: vec![],
1115            bids: vec![],
1116        };
1117        let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1118        assert!(md.contains("Market Health Report"));
1119        assert!(md.contains("USDCUSDT"));
1120        assert!(md.contains("Binance"));
1121        assert!(md.contains("Peg Target"));
1122        assert!(md.contains("1.0000"));
1123        assert!(md.contains("Healthy"));
1124    }
1125
1126    #[test]
1127    fn test_market_summary_to_markdown_no_prices() {
1128        use scope::market::{HealthCheck, MarketSummary};
1129        let summary = MarketSummary {
1130            pair: "TESTUSDT".to_string(),
1131            source: scope::market::BookSource::Exchange,
1132            peg_target: 1.0,
1133            peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1134            best_bid: None,
1135            best_ask: None,
1136            mid_price: None,
1137            spread: None,
1138            spread_pct: None,
1139            top_depth: Default::default(),
1140            volume_24h: None,
1141            bid_depth: 0.0,
1142            ask_depth: 0.0,
1143            bid_outliers: 0,
1144            ask_outliers: 0,
1145            healthy: false,
1146            bot_patterns: None,
1147            checks: vec![HealthCheck::Fail("No data".to_string())],
1148            execution_10k_buy: None,
1149            execution_10k_sell: None,
1150            asks: vec![],
1151            bids: vec![],
1152        };
1153        let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1154        assert!(md.contains("Market Health Report"));
1155        assert!(md.contains("-")); // missing data shown as "-"
1156    }
1157
1158    // ====================================================================
1159    // parse_duration — additional edge cases
1160    // ====================================================================
1161
1162    #[test]
1163    fn test_parse_duration_days() {
1164        assert_eq!(parse_duration("1d").unwrap(), 86400);
1165        assert_eq!(parse_duration("7d").unwrap(), 604800);
1166        assert_eq!(parse_duration("1day").unwrap(), 86400);
1167        assert_eq!(parse_duration("2days").unwrap(), 172800);
1168    }
1169
1170    #[test]
1171    fn test_parse_duration_long_names() {
1172        assert_eq!(parse_duration("30seconds").unwrap(), 30);
1173        assert_eq!(parse_duration("5minutes").unwrap(), 300);
1174        assert_eq!(parse_duration("2hours").unwrap(), 7200);
1175    }
1176
1177    #[test]
1178    fn test_parse_duration_fractional() {
1179        assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1180        assert_eq!(parse_duration("1.5m").unwrap(), 90);
1181    }
1182
1183    // ====================================================================
1184    // SummaryFormat tests
1185    // ====================================================================
1186
1187    #[test]
1188    fn test_summary_format_default() {
1189        let fmt = SummaryFormat::default();
1190        assert!(matches!(fmt, SummaryFormat::Text));
1191    }
1192
1193    #[test]
1194    fn test_summary_format_debug() {
1195        let text = format!("{:?}", SummaryFormat::Text);
1196        assert_eq!(text, "Text");
1197        let json = format!("{:?}", SummaryFormat::Json);
1198        assert_eq!(json, "Json");
1199    }
1200
1201    // ====================================================================
1202    // MarketCommands parsing tests
1203    // ====================================================================
1204
1205    #[test]
1206    fn test_ohlc_args_deserialization() {
1207        use crate::cli::{Cli, Commands};
1208        use clap::Parser;
1209        let cli = Cli::try_parse_from([
1210            "scope",
1211            "market",
1212            "ohlc",
1213            "USDC",
1214            "--venue",
1215            "binance",
1216            "--interval",
1217            "1h",
1218            "--limit",
1219            "50",
1220        ])
1221        .unwrap();
1222        if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1223            assert_eq!(args.pair, "USDC");
1224            assert_eq!(args.venue, "binance");
1225            assert_eq!(args.interval, "1h");
1226            assert_eq!(args.limit, 50);
1227        } else {
1228            panic!("Expected Market Ohlc command");
1229        }
1230    }
1231
1232    #[test]
1233    fn test_trades_args_deserialization() {
1234        use crate::cli::{Cli, Commands};
1235        use clap::Parser;
1236        let cli = Cli::try_parse_from([
1237            "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1238        ])
1239        .unwrap();
1240        if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1241            assert_eq!(args.pair, "BTC");
1242            assert_eq!(args.venue, "mexc");
1243            assert_eq!(args.limit, 100);
1244        } else {
1245            panic!("Expected Market Trades command");
1246        }
1247    }
1248
1249    #[test]
1250    fn test_base_symbol_from_pair_various_inputs() {
1251        // Additional coverage for edge cases
1252        assert_eq!(Pair::parse("USDC").base, "USDC");
1253        assert_eq!(Pair::parse("BTCUSDT").base, "BTC");
1254        assert_eq!(Pair::parse("ETH/USDT").base, "ETH");
1255        assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1256        assert_eq!(Pair::parse("X").base, "X"); // short symbol, no USDT suffix
1257        assert_eq!(Pair::parse("").base, "");
1258    }
1259
1260    #[test]
1261    fn test_summary_args_debug() {
1262        let args = SummaryArgs {
1263            pair: "USDC".to_string(),
1264            venue: "binance".to_string(),
1265            token_contract: None,
1266            chain: None,
1267            health: HealthArgs {
1268                peg_target: Some(1.0),
1269                peg_range: Some(0.001),
1270                min_levels: Some(6),
1271                min_depth: Some(3000.0),
1272                min_bid_ask_ratio: Some(0.2),
1273                max_bid_ask_ratio: Some(5.0),
1274                ..Default::default()
1275            },
1276            format: SummaryFormat::Text,
1277            every: None,
1278            duration: None,
1279            report: None,
1280            csv: None,
1281        };
1282        let debug = format!("{:?}", args);
1283        assert!(debug.contains("SummaryArgs"));
1284        assert!(debug.contains("USDC"));
1285    }
1286
1287    #[test]
1288    fn test_default_constants() {
1289        assert_eq!(DEFAULT_EVERY_SECS, 60);
1290        assert_eq!(DEFAULT_DURATION_SECS, 3600);
1291    }
1292
1293    #[test]
1294    fn test_markdown_leaves_out_the_peg_deviation_when_there_is_none() {
1295        // A peg target of 0 has no deviation: no "(+0.000%)", the same as the text report.
1296        use scope::market::{BookSource, OrderBook, OrderBookLevel};
1297        let level = |price| OrderBookLevel {
1298            price,
1299            quantity: 100.0,
1300        };
1301        let book = OrderBook {
1302            pair: "USDC/USDT".to_string(),
1303            source: BookSource::Exchange,
1304            bids: vec![level(0.9999)],
1305            asks: vec![level(1.0001)],
1306        };
1307        let thresholds = HealthThresholds {
1308            peg_target: 0.0,
1309            ..HealthThresholds::default()
1310        };
1311        let summary = MarketSummary::from_order_book(&book, &thresholds, None);
1312        let md = market_summary_to_markdown(&summary, "binance", "USDC");
1313        assert!(md.contains("0.9999") && !md.contains("0.9999 ("), "{md}");
1314        assert!(!md.contains("(+0.000%)"), "{md}");
1315    }
1316
1317    #[test]
1318    fn test_market_summary_to_markdown_with_execution_estimates() {
1319        use scope::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1320        let summary = MarketSummary {
1321            pair: "TESTUSDT".to_string(),
1322            source: scope::market::BookSource::Exchange,
1323            peg_target: 1.0,
1324            peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1325            best_bid: Some(0.9999),
1326            best_ask: Some(1.0001),
1327            mid_price: Some(1.0000),
1328            spread: Some(0.0002),
1329            spread_pct: None,
1330            top_depth: Default::default(),
1331            volume_24h: Some(1_000_000.0),
1332            bid_depth: 50_000.0,
1333            ask_depth: 50_000.0,
1334            bid_outliers: 0,
1335            ask_outliers: 0,
1336            healthy: true,
1337            bot_patterns: None,
1338            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1339            execution_10k_buy: Some(ExecutionEstimate {
1340                notional_usdt: 10_000.0,
1341                side: ExecutionSide::Buy,
1342                vwap: 1.0001,
1343                slippage_bps: 1.5,
1344                fillable: true,
1345            }),
1346            execution_10k_sell: Some(ExecutionEstimate {
1347                notional_usdt: 10_000.0,
1348                side: ExecutionSide::Sell,
1349                vwap: 0.0,
1350                slippage_bps: 0.0,
1351                fillable: false,
1352            }),
1353            asks: vec![],
1354            bids: vec![],
1355        };
1356        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1357        assert!(md.contains("Market Health Report"));
1358        assert!(md.contains("TESTUSDT"));
1359        assert!(md.contains("TestVenue"));
1360        // Check for fillable buy slippage (should show "1.50 bps")
1361        assert!(md.contains("1.50 bps"));
1362        // Check for unfillable sell (should show "insufficient")
1363        assert!(md.contains("insufficient"));
1364    }
1365
1366    #[tokio::test]
1367    async fn test_run_with_summary_command() {
1368        // Test the run() dispatcher with a DEX venue (doesn't require mock HTTP)
1369        let args = MarketCommands::Summary(SummaryArgs {
1370            pair: "USDC".to_string(),
1371            venue: "eth".to_string(),
1372            token_contract: None,
1373            chain: None,
1374            health: HealthArgs {
1375                peg_target: Some(1.0),
1376                peg_range: Some(0.01),
1377                min_levels: Some(1),
1378                min_depth: Some(50.0),
1379                min_bid_ask_ratio: Some(0.1),
1380                max_bid_ask_ratio: Some(10.0),
1381                ..Default::default()
1382            },
1383            format: SummaryFormat::Text,
1384            every: None,
1385            duration: None,
1386            report: None,
1387            csv: None,
1388        });
1389
1390        let http: std::sync::Arc<dyn scope::http::HttpClient> =
1391            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1392        let factory = DefaultClientFactory {
1393            chains_config: Default::default(),
1394            http,
1395        };
1396        let config = Config::default();
1397        let _result = run(args, &config, &factory, &quiet()).await;
1398        // Don't assert success - depends on network
1399    }
1400
1401    #[test]
1402    fn test_is_dex_venue() {
1403        assert!(DexVenue::parse("eth").is_some());
1404        assert!(DexVenue::parse("ethereum").is_some());
1405        assert!(DexVenue::parse("Ethereum").is_some());
1406        assert!(DexVenue::parse("solana").is_some());
1407        assert!(DexVenue::parse("Solana").is_some());
1408        assert!(DexVenue::parse("binance").is_none());
1409        assert!(DexVenue::parse("okx").is_none());
1410        assert!(DexVenue::parse("mexc").is_none());
1411    }
1412
1413    #[test]
1414    fn test_dex_venue_to_chain() {
1415        assert_eq!(
1416            DexVenue::parse("eth").map(DexVenue::chain),
1417            Some("ethereum")
1418        );
1419        assert_eq!(
1420            DexVenue::parse("ethereum").map(DexVenue::chain),
1421            Some("ethereum")
1422        );
1423        assert_eq!(
1424            DexVenue::parse("Ethereum").map(DexVenue::chain),
1425            Some("ethereum")
1426        );
1427        assert_eq!(
1428            DexVenue::parse("solana").map(DexVenue::chain),
1429            Some("solana")
1430        );
1431    }
1432
1433    #[test]
1434    fn test_venue_registry_loaded_in_cex_path() {
1435        // Verify the registry loads and can create an exchange client for any built-in venue
1436        let registry = VenueRegistry::load().unwrap();
1437        assert!(registry.contains("binance"));
1438        let client =
1439            registry.create_exchange_client_with_http("binance", scope::http::test_transport());
1440        assert!(client.is_ok());
1441    }
1442
1443    #[test]
1444    fn test_venue_registry_error_for_unknown() {
1445        let registry = VenueRegistry::load().unwrap();
1446        let result =
1447            registry.create_exchange_client_with_http("kracken", scope::http::test_transport());
1448        assert!(result.is_err());
1449        let err = result.unwrap_err().to_string();
1450        assert!(err.contains("Unknown venue"));
1451        assert!(err.contains("Did you mean")); // should suggest kraken (distance 1)
1452    }
1453
1454    #[tokio::test]
1455    async fn test_run_summary_json_format_with_mock() {
1456        let args = SummaryArgs {
1457            pair: "USDC".to_string(),
1458            venue: "eth".to_string(),
1459            token_contract: None,
1460            chain: None,
1461            health: HealthArgs {
1462                peg_target: Some(1.0),
1463                peg_range: Some(0.01),
1464                min_levels: Some(1),
1465                min_depth: Some(50.0),
1466                min_bid_ask_ratio: Some(0.1),
1467                max_bid_ask_ratio: Some(10.0),
1468                ..Default::default()
1469            },
1470            format: SummaryFormat::Json,
1471            every: None,
1472            duration: None,
1473            report: None,
1474            csv: None,
1475        };
1476
1477        let http: std::sync::Arc<dyn scope::http::HttpClient> =
1478            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1479        let factory = DefaultClientFactory {
1480            chains_config: Default::default(),
1481            http,
1482        };
1483        let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1484    }
1485
1486    // ====================================================================
1487    // OHLC command tests
1488    // ====================================================================
1489
1490    #[test]
1491    fn test_ohlc_format_default() {
1492        let fmt: OhlcFormat = Default::default();
1493        assert_eq!(fmt, OhlcFormat::Text);
1494    }
1495
1496    #[test]
1497    fn test_ohlc_format_display() {
1498        // ValueEnum-derived parsing
1499        assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1500        assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1501    }
1502
1503    #[test]
1504    fn test_ohlc_args_default_values() {
1505        // Verify we can construct OhlcArgs with defaults
1506        let args = OhlcArgs {
1507            pair: "BTC".to_string(),
1508            venue: "binance".to_string(),
1509            token_contract: None,
1510            interval: "1h".to_string(),
1511            limit: 100,
1512            format: OhlcFormat::Text,
1513        };
1514        assert_eq!(args.pair, "BTC");
1515        assert_eq!(args.venue, "binance");
1516        assert_eq!(args.interval, "1h");
1517        assert_eq!(args.limit, 100);
1518    }
1519
1520    #[test]
1521    fn test_trades_args_construction() {
1522        let args = TradesArgs {
1523            pair: "ETH".to_string(),
1524            venue: "okx".to_string(),
1525            token_contract: None,
1526            limit: 50,
1527            format: OhlcFormat::Json,
1528        };
1529        assert_eq!(args.pair, "ETH");
1530        assert_eq!(args.venue, "okx");
1531        assert_eq!(args.limit, 50);
1532    }
1533
1534    #[tokio::test]
1535    async fn test_run_ohlc_unknown_venue() {
1536        let args = OhlcArgs {
1537            pair: "BTC".to_string(),
1538            venue: "nonexistent_venue".to_string(),
1539            token_contract: None,
1540            interval: "1h".to_string(),
1541            limit: 10,
1542            format: OhlcFormat::Text,
1543        };
1544        let result = run_ohlc(args, scope::http::test_transport(), &quiet()).await;
1545        assert!(result.is_err());
1546        let err = result.unwrap_err().to_string();
1547        assert!(
1548            err.contains("Unknown venue"),
1549            "expected 'Unknown venue' error, got: {}",
1550            err
1551        );
1552    }
1553
1554    #[tokio::test]
1555    async fn test_run_trades_unknown_venue() {
1556        let args = TradesArgs {
1557            pair: "BTC".to_string(),
1558            venue: "nonexistent_venue".to_string(),
1559            token_contract: None,
1560            limit: 10,
1561            format: OhlcFormat::Text,
1562        };
1563        let result = run_trades(args, scope::http::test_transport(), &quiet()).await;
1564        assert!(result.is_err());
1565        let err = result.unwrap_err().to_string();
1566        assert!(
1567            err.contains("Unknown venue"),
1568            "expected 'Unknown venue' error, got: {}",
1569            err
1570        );
1571    }
1572
1573    #[tokio::test]
1574    async fn test_run_dispatches_ohlc() {
1575        let cmd = MarketCommands::Ohlc(OhlcArgs {
1576            pair: "BTC".to_string(),
1577            venue: "nonexistent_test_venue".to_string(),
1578            token_contract: None,
1579            interval: "1h".to_string(),
1580            limit: 5,
1581            format: OhlcFormat::Text,
1582        });
1583        let http: std::sync::Arc<dyn scope::http::HttpClient> =
1584            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1585        let factory = DefaultClientFactory {
1586            chains_config: Default::default(),
1587            http,
1588        };
1589        let config = Config::default();
1590        let result = run(cmd, &config, &factory, &quiet()).await;
1591        // Should error with venue not found
1592        assert!(result.is_err());
1593    }
1594
1595    #[tokio::test]
1596    async fn test_run_dispatches_trades() {
1597        let cmd = MarketCommands::Trades(TradesArgs {
1598            pair: "ETH".to_string(),
1599            venue: "nonexistent_test_venue".to_string(),
1600            token_contract: None,
1601            limit: 5,
1602            format: OhlcFormat::Json,
1603        });
1604        let http: std::sync::Arc<dyn scope::http::HttpClient> =
1605            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1606        let factory = DefaultClientFactory {
1607            chains_config: Default::default(),
1608            http,
1609        };
1610        let config = Config::default();
1611        let result = run(cmd, &config, &factory, &quiet()).await;
1612        assert!(result.is_err());
1613    }
1614
1615    /// A venue at `server` whose coin list is `/coins`.
1616    fn venue_with_coins(server: &mockito::ServerGuard) -> ExchangeClient {
1617        let mut desc = VenueRegistry::load()
1618            .unwrap()
1619            .get("binance")
1620            .unwrap()
1621            .clone();
1622        desc.base_url = server.url();
1623        desc.capabilities.assets =
1624            Some(serde_saphyr::from_str("path: /coins\nresponse: {}\n").unwrap());
1625        ExchangeClient::from_descriptor_with_http(&desc, scope::http::test_transport())
1626    }
1627
1628    async fn coins(server: &mut mockito::ServerGuard, body: &str) -> mockito::Mock {
1629        server
1630            .mock("GET", "/coins")
1631            .with_status(200)
1632            .with_body(body)
1633            .create_async()
1634            .await
1635    }
1636
1637    #[tokio::test]
1638    async fn test_a_cex_run_stops_when_the_saved_token_is_not_the_venues() {
1639        // A saved USDN on ethereum (0xAAA); the venue lists USDN on ETH as
1640        // 0xBBB. The summary must not report on the venue's token.
1641        let mut server = mockito::Server::new_async().await;
1642        let _m = coins(
1643            &mut server,
1644            r#"[{"coin":"USDN","networkList":[{"network":"ETH","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1645        )
1646        .await;
1647        let client = venue_with_coins(&server);
1648        let mut store = scope::tokens::TokenAliases::default();
1649        store.add_binding(
1650            "USDN",
1651            "ethereum",
1652            "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1653            "USDN",
1654            Some("binance"),
1655        );
1656        let err = check_cex_token(
1657            &client,
1658            "USDN",
1659            Some("ethereum".into()),
1660            None,
1661            &mut store,
1662            false,
1663            &quiet(),
1664        )
1665        .await
1666        .unwrap_err();
1667        assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1668    }
1669
1670    #[tokio::test]
1671    async fn test_a_cex_symbol_on_two_networks_stops_without_a_terminal() {
1672        let mut server = mockito::Server::new_async().await;
1673        let _m = coins(
1674            &mut server,
1675            r#"[{"coin":"USDN","networkList":[
1676                {"network":"ETH","contractAddress":"0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
1677                {"network":"BSC","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1678        )
1679        .await;
1680        let client = venue_with_coins(&server);
1681        let mut store = scope::tokens::TokenAliases::default();
1682        let err = check_cex_token(&client, "USDN", None, None, &mut store, false, &quiet())
1683            .await
1684            .unwrap_err();
1685        assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{err}");
1686        // With --token the run goes on.
1687        assert!(
1688            check_cex_token(
1689                &client,
1690                "USDN",
1691                None,
1692                Some("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into()),
1693                &mut store,
1694                false,
1695                &quiet()
1696            )
1697            .await
1698            .is_ok()
1699        );
1700    }
1701
1702    /// The binance mapping with a local reply, so the test needs no network (#39).
1703    fn binance_at(server: &mockito::ServerGuard) -> ExchangeClient {
1704        scope::market::testing::venue_at("binance", &server.url())
1705    }
1706
1707    const KLINES: &str = r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999],
1708        [1700003600000,"1.25","2.0","1.0","1.75","20.25",1700007199999]]"#;
1709    const TRADES: &str = r#"[{"id":1,"price":"1.5","qty":"2.5","quoteQty":"3.75","time":1700000000000,"isBuyerMaker":true},
1710        {"id":2,"price":"1.6","qty":"1.0","quoteQty":"1.6","time":1700000001000,"isBuyerMaker":false}]"#;
1711
1712    fn ohlc_args(format: OhlcFormat) -> OhlcArgs {
1713        OhlcArgs {
1714            pair: "BTC".to_string(),
1715            venue: "binance".to_string(),
1716            token_contract: None,
1717            interval: "1h".to_string(),
1718            limit: 2,
1719            format,
1720        }
1721    }
1722
1723    fn trades_args(format: OhlcFormat) -> TradesArgs {
1724        TradesArgs {
1725            pair: "BTC".to_string(),
1726            venue: "binance".to_string(),
1727            token_contract: None,
1728            limit: 2,
1729            format,
1730        }
1731    }
1732
1733    async fn serve(path: &str, body: &str) -> mockito::ServerGuard {
1734        let mut server = mockito::Server::new_async().await;
1735        server
1736            .mock("GET", path)
1737            .match_query(mockito::Matcher::UrlEncoded(
1738                "symbol".into(),
1739                "BTCUSDT".into(),
1740            ))
1741            .with_body(body)
1742            .create_async()
1743            .await;
1744        server
1745    }
1746
1747    #[tokio::test]
1748    async fn test_ohlc_text_shows_every_candle() {
1749        let server = serve("/api/v3/klines", KLINES).await;
1750        let (out, cap) = Output::capture();
1751        run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Text), &out)
1752            .await
1753            .unwrap();
1754        let text = crate::cli::ansi::strip(&cap.out());
1755        assert!(text.contains("OHLC — BTCUSDT (binance)"), "{text}");
1756        assert!(text.contains("1.250000"), "{text}");
1757        assert!(text.contains("1000.50"), "{text}");
1758        assert!(text.contains("2 candles returned"), "{text}");
1759    }
1760
1761    #[tokio::test]
1762    async fn test_ohlc_json_keeps_every_field() {
1763        let server = serve("/api/v3/klines", KLINES).await;
1764        let (out, cap) = Output::capture();
1765        run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Json), &out)
1766            .await
1767            .unwrap();
1768        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1769        assert_eq!(v.as_array().unwrap().len(), 2);
1770        assert_eq!(v[0]["open_time"], 1_700_000_000_000u64);
1771        assert_eq!(v[1]["close"], 1.75);
1772        assert_eq!(v[1]["volume"], 20.25);
1773    }
1774
1775    #[tokio::test]
1776    async fn test_trades_text_shows_side_price_and_quantity() {
1777        let server = serve("/api/v3/trades", TRADES).await;
1778        let (out, cap) = Output::capture();
1779        run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Text), &out)
1780            .await
1781            .unwrap();
1782        let text = crate::cli::ansi::strip(&cap.out());
1783        assert!(text.contains("Recent Trades — BTCUSDT (binance)"), "{text}");
1784        // isBuyerMaker true is a sell; false is a buy.
1785        assert!(text.contains("SELL") && text.contains("BUY"), "{text}");
1786        assert!(text.contains("1.500000") && text.contains("2.50"), "{text}");
1787        assert!(text.contains("2 trades returned"), "{text}");
1788    }
1789
1790    #[tokio::test]
1791    async fn test_trades_json_keeps_every_field() {
1792        let server = serve("/api/v3/trades", TRADES).await;
1793        let (out, cap) = Output::capture();
1794        run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Json), &out)
1795            .await
1796            .unwrap();
1797        let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1798        assert_eq!(v[0]["side"], "sell");
1799        assert_eq!(v[1]["side"], "buy");
1800        assert_eq!(v[0]["id"], "1");
1801        assert_eq!(v[0]["quote_quantity"], 3.75);
1802        assert_eq!(v[1]["timestamp_ms"], 1_700_000_001_000u64);
1803    }
1804
1805    // ====================================================================
1806    // parse_duration — additional edge cases for full coverage
1807    // ====================================================================
1808
1809    #[test]
1810    fn test_parse_duration_whitespace_empty() {
1811        assert!(parse_duration("").is_err());
1812        assert!(parse_duration("   ").is_err());
1813    }
1814
1815    #[test]
1816    fn test_parse_duration_sec_secs_second_seconds() {
1817        assert_eq!(parse_duration("1sec").unwrap(), 1);
1818        assert_eq!(parse_duration("2secs").unwrap(), 2);
1819        assert_eq!(parse_duration("1second").unwrap(), 1);
1820        assert_eq!(parse_duration("3seconds").unwrap(), 3);
1821    }
1822
1823    #[test]
1824    fn test_parse_duration_minute_minutes() {
1825        assert_eq!(parse_duration("1minute").unwrap(), 60);
1826        assert_eq!(parse_duration("2minutes").unwrap(), 120);
1827    }
1828
1829    #[test]
1830    fn test_parse_duration_hr_hrs_hour_hours() {
1831        assert_eq!(parse_duration("1hr").unwrap(), 3600);
1832        assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1833        assert_eq!(parse_duration("1hour").unwrap(), 3600);
1834        assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1835    }
1836
1837    #[test]
1838    fn test_parse_duration_number_only_defaults_to_seconds() {
1839        assert_eq!(parse_duration("1.5").unwrap(), 1);
1840        assert_eq!(parse_duration("42").unwrap(), 42);
1841    }
1842
1843    #[test]
1844    fn test_parse_duration_trimmed_input() {
1845        assert_eq!(parse_duration("  30s  ").unwrap(), 30);
1846        assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1847    }
1848
1849    #[test]
1850    fn test_parse_duration_invalid_number_format() {
1851        assert!(parse_duration("1.2.3s").is_err());
1852        assert!(parse_duration("abc").is_err());
1853    }
1854
1855    // ====================================================================
1856    // An unknown venue is not a DEX venue (it used to map to ethereum)
1857    // ====================================================================
1858
1859    #[test]
1860    fn test_unknown_venue_is_not_a_dex_venue() {
1861        assert_eq!(DexVenue::parse("binance"), None);
1862        assert_eq!(DexVenue::parse("kraken"), None);
1863        assert_eq!(DexVenue::parse("unknown"), None);
1864    }
1865
1866    // ====================================================================
1867    // market_summary_to_markdown — mixed Pass/Fail checks
1868    // ====================================================================
1869
1870    #[test]
1871    fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1872        use scope::market::{HealthCheck, MarketSummary};
1873        let summary = MarketSummary {
1874            pair: "TESTUSDT".to_string(),
1875            source: scope::market::BookSource::Exchange,
1876            peg_target: 1.0,
1877            peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1878            best_bid: Some(0.9999),
1879            best_ask: Some(1.0001),
1880            mid_price: Some(1.0000),
1881            spread: Some(0.0002),
1882            spread_pct: None,
1883            top_depth: Default::default(),
1884            volume_24h: Some(500_000.0),
1885            bid_depth: 40_000.0,
1886            ask_depth: 45_000.0,
1887            bid_outliers: 0,
1888            ask_outliers: 0,
1889            healthy: false,
1890            bot_patterns: None,
1891            checks: vec![
1892                HealthCheck::Pass("Spread within range".to_string()),
1893                HealthCheck::Fail("Bid depth below minimum".to_string()),
1894            ],
1895            execution_10k_buy: None,
1896            execution_10k_sell: None,
1897            asks: vec![],
1898            bids: vec![],
1899        };
1900        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1901        assert!(md.contains("✓ Spread within range"));
1902        assert!(md.contains("✗ Bid depth below minimum"));
1903        assert!(md.contains("✗")); // unhealthy indicator in table
1904    }
1905
1906    #[test]
1907    fn test_market_summary_to_markdown_empty_checks() {
1908        use scope::market::MarketSummary;
1909        let summary = MarketSummary {
1910            pair: "X".to_string(),
1911            source: scope::market::BookSource::Exchange,
1912            peg_target: 1.0,
1913            peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1914            best_bid: Some(1.0),
1915            best_ask: Some(1.0),
1916            mid_price: Some(1.0),
1917            spread: Some(0.0),
1918            spread_pct: None,
1919            top_depth: Default::default(),
1920            volume_24h: None,
1921            bid_depth: 100.0,
1922            ask_depth: 100.0,
1923            bid_outliers: 0,
1924            ask_outliers: 0,
1925            healthy: true,
1926            bot_patterns: None,
1927            checks: vec![],
1928            execution_10k_buy: None,
1929            execution_10k_sell: None,
1930            asks: vec![],
1931            bids: vec![],
1932        };
1933        let md = market_summary_to_markdown(&summary, "Venue", "X");
1934        assert!(md.contains("Market Health Report"));
1935        // No checks, no checks section.
1936        assert!(!md.contains("Health Checks:"));
1937    }
1938
1939    // ====================================================================
1940    // OhlcFormat / SummaryFormat trait coverage
1941    // ====================================================================
1942
1943    #[test]
1944    fn test_ohlc_format_partial_eq() {
1945        assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1946        assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1947        assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1948    }
1949
1950    #[test]
1951    fn test_summary_format_clone_copy() {
1952        let text = SummaryFormat::Text;
1953        let cloned = text;
1954        assert!(matches!(cloned, SummaryFormat::Text));
1955        assert!(matches!(text, SummaryFormat::Text));
1956    }
1957
1958    // ====================================================================
1959    // run_summary — error paths and report/csv
1960    // ====================================================================
1961
1962    #[tokio::test]
1963    async fn test_run_summary_interval_zero_error() {
1964        let args = SummaryArgs {
1965            pair: "USDC".to_string(),
1966            venue: "eth".to_string(),
1967            token_contract: None,
1968            chain: None,
1969            health: HealthArgs {
1970                peg_target: Some(1.0),
1971                peg_range: Some(0.01),
1972                min_levels: Some(1),
1973                min_depth: Some(50.0),
1974                min_bid_ask_ratio: Some(0.1),
1975                max_bid_ask_ratio: Some(10.0),
1976                ..Default::default()
1977            },
1978            format: SummaryFormat::Text,
1979            every: Some("0.1s".to_string()),
1980            duration: Some("1m".to_string()),
1981            report: None,
1982            csv: None,
1983        };
1984        let http: std::sync::Arc<dyn scope::http::HttpClient> =
1985            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1986        let factory = DefaultClientFactory {
1987            chains_config: Default::default(),
1988            http,
1989        };
1990        let result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1991        assert!(result.is_err());
1992        let err = result.unwrap_err().to_string();
1993        assert!(
1994            err.contains("Interval must be positive") || err.contains("positive"),
1995            "expected interval error, got: {}",
1996            err
1997        );
1998    }
1999
2000    // ====================================================================
2001    // MarketCommands and struct Debug/construction
2002    // ====================================================================
2003
2004    #[test]
2005    fn test_market_commands_debug() {
2006        let cmd = MarketCommands::Summary(SummaryArgs {
2007            pair: "USDC".to_string(),
2008            venue: "binance".to_string(),
2009            token_contract: None,
2010            chain: None,
2011            health: HealthArgs {
2012                peg_target: Some(1.0),
2013                peg_range: Some(0.001),
2014                min_levels: Some(6),
2015                min_depth: Some(3000.0),
2016                min_bid_ask_ratio: Some(0.2),
2017                max_bid_ask_ratio: Some(5.0),
2018                ..Default::default()
2019            },
2020            format: SummaryFormat::Text,
2021            every: None,
2022            duration: None,
2023            report: None,
2024            csv: None,
2025        });
2026        let debug = format!("{:?}", cmd);
2027        assert!(debug.contains("Summary"));
2028
2029        let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
2030            pair: "BTC".to_string(),
2031            venue: "binance".to_string(),
2032            token_contract: None,
2033            interval: "1h".to_string(),
2034            limit: 100,
2035            format: OhlcFormat::Text,
2036        });
2037        assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
2038
2039        let trades_cmd = MarketCommands::Trades(TradesArgs {
2040            pair: "ETH".to_string(),
2041            venue: "binance".to_string(),
2042            token_contract: None,
2043            limit: 50,
2044            format: OhlcFormat::Json,
2045        });
2046        assert!(format!("{:?}", trades_cmd).contains("Trades"));
2047    }
2048
2049    #[test]
2050    fn test_summary_args_with_report_csv_options() {
2051        let args = SummaryArgs {
2052            pair: "DAI".to_string(),
2053            venue: "binance".to_string(),
2054            token_contract: None,
2055            chain: None,
2056            health: HealthArgs {
2057                peg_target: Some(1.0),
2058                peg_range: Some(0.001),
2059                min_levels: Some(6),
2060                min_depth: Some(3000.0),
2061                min_bid_ask_ratio: Some(0.2),
2062                max_bid_ask_ratio: Some(5.0),
2063                ..Default::default()
2064            },
2065            format: SummaryFormat::Json,
2066            every: Some("30s".to_string()),
2067            duration: Some("1h".to_string()),
2068            report: Some(std::path::PathBuf::from("/tmp/report.md")),
2069            csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
2070        };
2071        assert_eq!(args.pair, "DAI");
2072        assert_eq!(args.venue, "binance");
2073        assert!(args.every.is_some());
2074        assert!(args.duration.is_some());
2075        assert!(args.report.is_some());
2076        assert!(args.csv.is_some());
2077    }
2078
2079    #[test]
2080    fn test_base_symbol_from_pair_4char_usdt() {
2081        // "USDT" itself has len 4, so doesn't satisfy p.len() > 4
2082        assert_eq!(Pair::parse("USDT").base, "USDT");
2083    }
2084
2085    #[test]
2086    fn test_market_summary_to_markdown_unhealthy() {
2087        use scope::market::{HealthCheck, MarketSummary};
2088        let summary = MarketSummary {
2089            pair: "X".to_string(),
2090            source: scope::market::BookSource::Exchange,
2091            peg_target: 1.0,
2092            peg_range: scope::market::health::DEFAULT_PEG_RANGE,
2093            best_bid: Some(0.99),
2094            best_ask: Some(1.01),
2095            mid_price: Some(1.0),
2096            spread: Some(0.02),
2097            spread_pct: None,
2098            top_depth: Default::default(),
2099            volume_24h: None,
2100            bid_depth: 100.0,
2101            ask_depth: 100.0,
2102            bid_outliers: 0,
2103            ask_outliers: 0,
2104            healthy: false,
2105            bot_patterns: None,
2106            checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
2107            execution_10k_buy: None,
2108            execution_10k_sell: None,
2109            asks: vec![],
2110            bids: vec![],
2111        };
2112        let md = market_summary_to_markdown(&summary, "Test", "X");
2113        assert!(md.contains("✗"));
2114        assert!(md.contains("Peg deviation too high"));
2115    }
2116
2117    #[test]
2118    fn test_ohlc_format_eq() {
2119        assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
2120        assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
2121    }
2122
2123    #[test]
2124    fn test_trades_args_default_venue() {
2125        let args = TradesArgs {
2126            pair: "USDC".to_string(),
2127            venue: "binance".to_string(),
2128            token_contract: None,
2129            limit: 50,
2130            format: OhlcFormat::Text,
2131        };
2132        assert_eq!(args.pair, "USDC");
2133        assert_eq!(args.venue, "binance");
2134    }
2135
2136    // ------------------------------------------------------------------
2137    // Offline DEX-path tests (mock factory, no network)
2138    // ------------------------------------------------------------------
2139
2140    const USDC_ADDR: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
2141
2142    /// Mock factory whose DEX data has the given pairs (liquidity, volume).
2143    fn dex_factory(pairs: &[(f64, f64)]) -> scope::chains::mocks::MockClientFactory {
2144        let mut factory = scope::chains::mocks::MockClientFactory::new();
2145        let data = factory.mock_dex.token_data.as_mut().unwrap();
2146        data.symbol = "USDC".to_string();
2147        data.pairs = pairs
2148            .iter()
2149            .enumerate()
2150            .map(|(i, &(liquidity_usd, volume_24h))| scope::chains::DexPair {
2151                dex_name: format!("DEX {i}"),
2152                pair_address: format!("0xpair{i}"),
2153                base_token: "USDC".to_string(),
2154                quote_token: "USDT".to_string(),
2155                price_usd: 1.0,
2156                volume_24h,
2157                liquidity_usd,
2158                liquidity_base: None,
2159                price_change_24h: 0.0,
2160                buys_24h: 0,
2161                sells_24h: 0,
2162                buys_6h: 0,
2163                sells_6h: 0,
2164                buys_1h: 0,
2165                sells_1h: 0,
2166                pair_created_at: None,
2167                url: None,
2168            })
2169            .collect();
2170        factory
2171    }
2172
2173    fn dex_args(format: SummaryFormat) -> SummaryArgs {
2174        SummaryArgs {
2175            pair: USDC_ADDR.to_string(),
2176            venue: "eth".to_string(),
2177            token_contract: None,
2178            chain: None,
2179            health: HealthArgs::default(),
2180            format,
2181            every: None,
2182            duration: None,
2183            report: None,
2184            csv: None,
2185        }
2186    }
2187
2188    fn two_chain_usdn(factory: &mut scope::chains::mocks::MockClientFactory) {
2189        let usdn = |chain: &str, address: &str| scope::chains::dex::TokenSearchResult {
2190            address: address.into(),
2191            symbol: "USDN".into(),
2192            name: "USDN".into(),
2193            chain: chain.into(),
2194            price_usd: Some(1.0),
2195            volume_24h: 0.0,
2196            liquidity_usd: 1.0,
2197            market_cap: None,
2198        };
2199        factory.mock_dex.search_results = vec![usdn("bsc", "0xB"), usdn("ethereum", "0xA")];
2200    }
2201
2202    #[tokio::test]
2203    async fn test_a_dex_venue_resolves_on_its_own_chain() {
2204        // Review C2: the `eth` venue must not offer, or report, a bsc token.
2205        let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2206        two_chain_usdn(&mut factory);
2207        let mut args = dex_args(SummaryFormat::Text);
2208        args.pair = "USDN".into();
2209        let token = resolve_market_token(&args, &factory, &quiet())
2210            .await
2211            .unwrap();
2212        assert_eq!(token.as_deref(), Some("0xA"));
2213    }
2214
2215    #[tokio::test]
2216    async fn test_a_dex_venue_takes_the_token_flag_without_a_search() {
2217        // Review C1: --token on a DEX venue was ignored.
2218        let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2219        two_chain_usdn(&mut factory);
2220        let mut args = dex_args(SummaryFormat::Text);
2221        args.pair = "USDN".into();
2222        args.token_contract = Some("0xT".into());
2223        let token = resolve_market_token(&args, &factory, &quiet())
2224            .await
2225            .unwrap();
2226        assert_eq!(token.as_deref(), Some("0xT"));
2227    }
2228
2229    #[tokio::test]
2230    async fn test_dex_book_uses_most_liquid_pair() {
2231        // The synthetic book must come from the deepest pool, not the first one.
2232        let factory = dex_factory(&[(1_000.0, 10.0), (50_000.0, 777.0), (2_000.0, 20.0)]);
2233        let (book, volume, trades) = fetch_book_and_volume(
2234            &dex_args(SummaryFormat::Text),
2235            &HealthThresholds::default(),
2236            &factory,
2237            &quiet(),
2238        )
2239        .await
2240        .unwrap();
2241        assert_eq!(volume, Some(777.0));
2242        // A DEX pool has no tape: the bot-pattern advisory says so.
2243        assert_eq!(trades.unwrap_err(), "a DEX pool has no trade tape here");
2244        let depth = book.total_depth(scope::market::BookSide::Bid)
2245            + book.total_depth(scope::market::BookSide::Ask);
2246        assert!((depth - 50_000.0).abs() < 1.0, "depth {depth}");
2247    }
2248
2249    /// `--venue sol` is the Solana DEX (it used to go to the CEX registry
2250    /// and fail with "unknown venue").
2251    #[tokio::test]
2252    async fn test_venue_sol_reads_the_solana_dex() {
2253        let factory = dex_factory(&[(50_000.0, 777.0)]);
2254        let mut args = dex_args(SummaryFormat::Text);
2255        args.venue = "sol".to_string();
2256        let result =
2257            fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet()).await;
2258        let (book, volume, _trades) = result.unwrap();
2259        assert_eq!(volume, Some(777.0));
2260        assert_eq!(book.pair, "USDC/USD");
2261    }
2262
2263    /// `--chain` is one rule with the venue: a chain that is not the DEX
2264    /// venue's chain is an error, in any case.
2265    #[tokio::test]
2266    async fn test_chain_must_match_the_dex_venue() {
2267        let factory = dex_factory(&[(50_000.0, 1.0)]);
2268        let mut args = dex_args(SummaryFormat::Text);
2269        args.chain = Some("Solana".to_string());
2270        let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2271            .await
2272            .unwrap_err();
2273        assert!(err.to_string().contains("reads chain ethereum"), "{err}");
2274
2275        args.chain = Some("ETH".to_string());
2276        assert!(
2277            fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2278                .await
2279                .is_ok()
2280        );
2281    }
2282
2283    #[tokio::test]
2284    async fn test_unknown_venue_is_an_error() {
2285        let factory = dex_factory(&[(50_000.0, 1.0)]);
2286        let mut args = dex_args(SummaryFormat::Text);
2287        args.venue = "polygon".to_string();
2288        let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2289            .await
2290            .unwrap_err();
2291        assert!(
2292            err.to_string().contains("Unknown venue \"polygon\""),
2293            "{err}"
2294        );
2295    }
2296
2297    #[tokio::test]
2298    async fn test_dex_no_pairs_is_error() {
2299        let factory = dex_factory(&[]);
2300        let err = fetch_book_and_volume(
2301            &dex_args(SummaryFormat::Text),
2302            &HealthThresholds::default(),
2303            &factory,
2304            &quiet(),
2305        )
2306        .await
2307        .unwrap_err();
2308        assert!(err.to_string().contains("No DEX pairs found"), "{err}");
2309    }
2310
2311    #[tokio::test]
2312    async fn test_run_summary_once_json_reflects_resolved_thresholds() {
2313        let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2314        let mut args = dex_args(SummaryFormat::Json);
2315        // A one-level synthetic book is healthy only with loosened thresholds.
2316        args.health = HealthArgs {
2317            min_levels: Some(1),
2318            min_top10_depth: Some(1.0),
2319            ..Default::default()
2320        };
2321        let thresholds = args.health.resolve(&Config::default());
2322        let (summary, _) = summary_run(&args, &factory, &thresholds, &[], Some(1), &quiet())
2323            .await
2324            .unwrap();
2325        assert!(summary.healthy, "{:?}", summary.checks);
2326
2327        let strict = HealthThresholds::default();
2328        let (summary, _) = summary_run(&args, &factory, &strict, &[], None, &quiet())
2329            .await
2330            .unwrap();
2331        assert!(!summary.healthy);
2332    }
2333
2334    #[tokio::test]
2335    async fn test_dex_json_shows_amm_source_and_na_checks() {
2336        // #37 acceptance: JSON shows "status": "n/a", and the book source.
2337        let mut factory = dex_factory(&[(20_000_000.0, 1_000.0)]);
2338        factory.mock_dex.token_data.as_mut().unwrap().pairs[0].liquidity_base = Some(10_000_000.0);
2339        let args = dex_args(SummaryFormat::Json);
2340        let thresholds = args.health.resolve(&Config::default());
2341        let (summary, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
2342            .await
2343            .unwrap();
2344        assert!(summary.healthy, "{:?}", summary.checks);
2345        let v: serde_json::Value = serde_json::from_str(&report).unwrap();
2346        assert_eq!(v["book_source"], "synthetic_amm");
2347        let statuses: Vec<&str> = v["checks"]
2348            .as_array()
2349            .unwrap()
2350            .iter()
2351            .map(|c| c["status"].as_str().unwrap())
2352            .collect();
2353        // Three book checks, and the bot-pattern advisory: a pool has no
2354        // tape, and its curve is an even ladder by design.
2355        assert_eq!(
2356            statuses.iter().filter(|s| **s == "n/a").count(),
2357            4,
2358            "{:?}",
2359            statuses
2360        );
2361        assert!(!statuses.contains(&"fail"));
2362        assert_eq!(
2363            v["checks"].as_array().unwrap().last().unwrap()["message"],
2364            "Bot patterns: not checked (a DEX pool has no trade tape here)"
2365        );
2366        assert_eq!(v["bot_patterns"]["book_checked"], false);
2367    }
2368
2369    #[tokio::test]
2370    async fn test_dex_without_reserves_says_estimate() {
2371        let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2372        let args = dex_args(SummaryFormat::Text);
2373        let thresholds = args.health.resolve(&Config::default());
2374        let (_, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
2375            .await
2376            .unwrap();
2377        assert!(report.contains("pool reserves not reported"), "{report}");
2378    }
2379
2380    #[tokio::test]
2381    async fn test_run_summary_one_shot_writes_report() {
2382        let dir = tempfile::tempdir().unwrap();
2383        let report = dir.path().join("report.md");
2384        let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2385        let mut args = dex_args(SummaryFormat::Text);
2386        args.report = Some(report.clone());
2387        run_summary(args, &Config::default(), &factory, &quiet())
2388            .await
2389            .unwrap();
2390        let md = std::fs::read_to_string(&report).unwrap();
2391        assert!(md.contains("Market Health Report"), "{md}");
2392        assert!(md.contains(USDC_ADDR), "{md}");
2393    }
2394
2395    // The paused clock: a slow run on a loaded machine must not change
2396    // the count.
2397    #[tokio::test(start_paused = true)]
2398    async fn test_run_summary_repeat_mode_writes_csv_rows_and_report() {
2399        let dir = tempfile::tempdir().unwrap();
2400        let csv = dir.path().join("series.csv");
2401        let report = dir.path().join("final.md");
2402        let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2403        let mut args = dex_args(SummaryFormat::Json);
2404        args.every = Some("1s".to_string());
2405        args.duration = Some("1s".to_string());
2406        args.csv = Some(csv.clone());
2407        args.report = Some(report.clone());
2408        run_summary(args, &Config::default(), &factory, &quiet())
2409            .await
2410            .unwrap();
2411
2412        let body = std::fs::read_to_string(&csv).unwrap();
2413        let mut lines = body.lines();
2414        // Today's columns stay first and in order; #58 appends the rest.
2415        let header = lines.next().unwrap();
2416        assert!(
2417            header.starts_with(
2418                "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy,"
2419            ),
2420            "{header}"
2421        );
2422        assert!(
2423            header.contains("spread_pct") && header.ends_with("ask_top10_depth"),
2424            "{header}"
2425        );
2426        // One run at t=0, one after the 1s sleep: the loop must not stop early.
2427        let rows: Vec<_> = lines.collect();
2428        assert_eq!(rows.len(), 2, "{body}");
2429        assert!(rows[0].split(',').nth(1) == Some("1"));
2430        assert!(rows[1].split(',').nth(1) == Some("2"));
2431        assert!(report.exists());
2432    }
2433
2434    // The paused clock: a slow run on a loaded machine must not change
2435    // the count.
2436    #[tokio::test(start_paused = true)]
2437    async fn test_run_summary_repeat_publishes_run_progress() {
2438        // The TUI draws `run N of M` from this hook, so the total must be
2439        // known at the start and the run number must advance.
2440        let (out, captured) = Output::capture_merged();
2441        let hook = out.progress_hook().expect("a merged capture has a hook");
2442        let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2443        let mut args = dex_args(SummaryFormat::Json);
2444        args.every = Some("1s".to_string());
2445        args.duration = Some("2s".to_string());
2446        run_summary(args, &Config::default(), &factory, &out)
2447            .await
2448            .unwrap();
2449        let p = hook.lock().unwrap().clone();
2450        // Runs at 0s, 1s and 2s.
2451        assert_eq!(p.total, Some(3), "{p:?}");
2452        assert_eq!(p.run, 3, "{p:?}");
2453        assert_eq!(p.next_at, None, "no next run after the last: {p:?}");
2454        assert!(captured.out().contains("every 1s for 2s"));
2455        assert!(captured.out().contains("Completed 3 run(s)"));
2456    }
2457
2458    #[tokio::test]
2459    async fn test_a_failed_run_ends_a_repeating_summary() {
2460        // A run that errors stops the repeat and returns the error, so the
2461        // pane footer is `✗ Failed in`. It does not skip to the next run.
2462        let (out, _captured) = Output::capture_merged();
2463        let hook = out.progress_hook().unwrap();
2464        let factory = dex_factory(&[]);
2465        let mut args = dex_args(SummaryFormat::Json);
2466        args.every = Some("1s".to_string());
2467        args.duration = Some("30s".to_string());
2468        let started = std::time::Instant::now();
2469        let result = run_summary(args, &Config::default(), &factory, &out).await;
2470        assert!(result.is_err(), "no DEX pairs is a failed run");
2471        assert_eq!(hook.lock().unwrap().run, 1, "no second run after the error");
2472        assert!(
2473            started.elapsed() < Duration::from_secs(5),
2474            "it did not sleep on"
2475        );
2476    }
2477}