Skip to main content

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