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