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