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::error::{Result, ScopeError};
12use crate::market::{
13    HealthThresholds, MarketSummary, OrderBook, VenueRegistry, order_book_from_analytics,
14};
15use clap::{Args, Subcommand};
16use std::time::Duration;
17
18/// Default interval between summary runs when in repeat mode (60 seconds).
19pub const DEFAULT_EVERY_SECS: u64 = 60;
20
21/// Default total duration when in repeat mode (1 hour).
22pub const DEFAULT_DURATION_SECS: u64 = 3600;
23
24/// Market subcommands.
25#[derive(Debug, Subcommand)]
26pub enum MarketCommands {
27    /// One-screen peg and order book health summary.
28    ///
29    /// Displays best bid/ask, mid price, spread, order book levels,
30    /// and configurable health checks (peg safety, bid/ask ratio, depth thresholds).
31    ///
32    /// Use --every and --duration to run repeatedly (e.g., every 30s for 1 hour).
33    Summary(SummaryArgs),
34}
35
36/// Arguments for `scope market summary`.
37///
38/// Default thresholds (min_levels, min_depth, peg_range) originated from the
39/// PUSD Hummingbot market-making config and are tunable for other markets.
40#[derive(Debug, Args)]
41pub struct SummaryArgs {
42    /// Base token symbol (e.g., USDC, PUSD). Quote is USDT.
43    #[arg(default_value = "USDC", value_name = "SYMBOL")]
44    pub pair: String,
45
46    /// Market venue (e.g., binance, biconomy, mexc, okx, eth, solana).
47    /// Use `scope venues list` to see all available venues.
48    #[arg(long, default_value = "binance", value_name = "VENUE")]
49    pub venue: String,
50
51    /// Chain for DEX venues (ethereum or solana). Ignored for CEX.
52    #[arg(long, default_value = "ethereum", value_name = "CHAIN")]
53    pub chain: String,
54
55    /// Peg target (e.g., 1.0 for USD stablecoins).
56    #[arg(long, default_value = "1.0", value_name = "TARGET")]
57    pub peg: f64,
58
59    /// Minimum order book levels per side (default from PUSD Hummingbot config).
60    #[arg(long, default_value = "6", value_name = "N")]
61    pub min_levels: usize,
62
63    /// Minimum depth per side in quote terms, e.g. USDT (default from PUSD Hummingbot config).
64    #[arg(long, default_value = "3000", value_name = "USDT")]
65    pub min_depth: f64,
66
67    /// Peg range for outlier filtering (orders outside peg ± range×5 excluded).
68    /// E.g., 0.001 = ±0.5% around peg.
69    #[arg(long, default_value = "0.001", value_name = "RANGE")]
70    pub peg_range: f64,
71
72    /// Min bid/ask depth ratio (warn if ratio below this).
73    #[arg(long, default_value = "0.2", value_name = "RATIO")]
74    pub min_bid_ask_ratio: f64,
75
76    /// Max bid/ask depth ratio (warn if ratio above this).
77    #[arg(long, default_value = "5.0", value_name = "RATIO")]
78    pub max_bid_ask_ratio: f64,
79
80    /// Output format.
81    #[arg(short, long, default_value = "text")]
82    pub format: SummaryFormat,
83
84    /// Run repeatedly at this interval (e.g., 30s, 5m, 1h).
85    /// Default when in repeat mode: 60s.
86    #[arg(long, value_name = "INTERVAL")]
87    pub every: Option<String>,
88
89    /// Run for this total duration (e.g., 10m, 1h, 24h).
90    /// Default when in repeat mode: 1h.
91    #[arg(long, value_name = "DURATION")]
92    pub duration: Option<String>,
93
94    /// Save markdown report to file (one-shot mode) or final report (repeat mode).
95    #[arg(long, value_name = "PATH")]
96    pub report: Option<std::path::PathBuf>,
97
98    /// Append time-series CSV of peg/spread/depth to this path (repeat mode only).
99    #[arg(long, value_name = "PATH")]
100    pub csv: Option<std::path::PathBuf>,
101}
102
103#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
104pub enum SummaryFormat {
105    /// Human-readable text report (default).
106    #[default]
107    Text,
108    /// JSON for programmatic consumption.
109    Json,
110}
111
112/// Run the market command.
113pub async fn run(
114    args: MarketCommands,
115    _config: &Config,
116    factory: &dyn ChainClientFactory,
117) -> Result<()> {
118    match args {
119        MarketCommands::Summary(summary_args) => run_summary(summary_args, factory).await,
120    }
121}
122
123/// Parse duration strings like "30s", "5m", "1h", "24h" into seconds.
124/// Extract base symbol from pair (e.g. "PUSD_USDT" -> "PUSD", "USDCUSDT" -> "USDC", "USDC" -> "USDC").
125fn base_symbol_from_pair(pair: &str) -> &str {
126    let p = pair.trim();
127    if let Some(i) = p.find("_USDT") {
128        return &p[..i];
129    }
130    if let Some(i) = p.find("_usdt") {
131        return &p[..i];
132    }
133    if let Some(i) = p.find("/USDT") {
134        return &p[..i];
135    }
136    if p.to_uppercase().ends_with("USDT") && p.len() > 4 {
137        return &p[..p.len() - 4];
138    }
139    p
140}
141
142fn parse_duration(s: &str) -> Result<u64> {
143    let s = s.trim();
144    if s.is_empty() {
145        return Err(ScopeError::Chain("Empty duration".to_string()));
146    }
147    let (num_str, unit) = s
148        .char_indices()
149        .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
150        .map(|(i, _)| (&s[..i], s[i..].trim()))
151        .unwrap_or((s, "s"));
152
153    let num: f64 = num_str
154        .parse()
155        .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
156
157    if num <= 0.0 {
158        return Err(ScopeError::Chain("Duration must be positive".to_string()));
159    }
160
161    let secs = match unit.to_lowercase().as_str() {
162        "s" | "sec" | "secs" | "second" | "seconds" => num,
163        "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
164        "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
165        "d" | "day" | "days" => num * 86400.0,
166        _ => {
167            return Err(ScopeError::Chain(format!(
168                "Unknown duration unit: {}",
169                unit
170            )));
171        }
172    };
173
174    Ok(secs as u64)
175}
176
177/// Builds markdown report content for a market summary.
178fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
179    let bid_dev = summary
180        .best_bid
181        .map(|b| (b - summary.peg_target) / summary.peg_target * 100.0);
182    let ask_dev = summary
183        .best_ask
184        .map(|a| (a - summary.peg_target) / summary.peg_target * 100.0);
185    let volume_row = summary
186        .volume_24h
187        .map(|v| format!("| Volume (24h) | {:.0} USDT |  \n", v))
188        .unwrap_or_default();
189    let exec_buy = summary
190        .execution_10k_buy
191        .as_ref()
192        .map(|e| {
193            if e.fillable {
194                format!("{:.2} bps", e.slippage_bps)
195            } else {
196                "insufficient".to_string()
197            }
198        })
199        .unwrap_or_else(|| "-".to_string());
200    let exec_sell = summary
201        .execution_10k_sell
202        .as_ref()
203        .map(|e| {
204            if e.fillable {
205                format!("{:.2} bps", e.slippage_bps)
206            } else {
207                "insufficient".to_string()
208            }
209        })
210        .unwrap_or_else(|| "-".to_string());
211    let mut md = format!(
212        "# Market Health Report: {}  \n\
213        **Venue:** {}  \n\
214        **Generated:** {}  \n\n\
215        ## Peg & Spread  \n\
216        | Metric | Value |  \n\
217        |--------|-------|  \n\
218        | Peg Target | {:.4} |  \n\
219        | Best Bid | {} |  \n\
220        | Best Ask | {} |  \n\
221        | Mid Price | {} |  \n\
222        | Spread | {} |  \n\
223        {}\
224        | 10k Buy Slippage | {} |  \n\
225        | 10k Sell Slippage | {} |  \n\
226        | Bid Depth | {:.0} |  \n\
227        | Ask Depth | {:.0} |  \n\
228        | Healthy | {} |  \n\n\
229        ## Health Checks  \n",
230        pair,
231        venue,
232        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
233        summary.peg_target,
234        summary
235            .best_bid
236            .map(|b| format!("{:.4} ({:+.3}%)", b, bid_dev.unwrap_or(0.0)))
237            .unwrap_or_else(|| "-".to_string()),
238        summary
239            .best_ask
240            .map(|a| format!("{:.4} ({:+.3}%)", a, ask_dev.unwrap_or(0.0)))
241            .unwrap_or_else(|| "-".to_string()),
242        summary
243            .mid_price
244            .map(|m| format!("{:.4}", m))
245            .unwrap_or_else(|| "-".to_string()),
246        summary
247            .spread
248            .map(|s| format!("{:.4}", s))
249            .unwrap_or_else(|| "-".to_string()),
250        volume_row,
251        exec_buy,
252        exec_sell,
253        summary.bid_depth,
254        summary.ask_depth,
255        if summary.healthy { "✓" } else { "✗" }
256    );
257    for check in &summary.checks {
258        let (icon, msg) = match check {
259            crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
260            crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
261        };
262        md.push_str(&format!("- {} {}\n", icon, msg));
263    }
264    md.push_str(&crate::display::report::report_footer());
265    md
266}
267
268/// Whether the venue string refers to a DEX venue (handled by DexScreener, not the registry).
269fn is_dex_venue(venue: &str) -> bool {
270    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
271}
272
273/// Resolve DEX venue name to a canonical chain name.
274fn dex_venue_to_chain(venue: &str) -> &str {
275    match venue.to_lowercase().as_str() {
276        "ethereum" | "eth" => "ethereum",
277        "solana" => "solana",
278        _ => "ethereum",
279    }
280}
281
282async fn fetch_book_and_volume(
283    args: &SummaryArgs,
284    factory: &dyn ChainClientFactory,
285) -> Result<(OrderBook, Option<f64>)> {
286    let base = base_symbol_from_pair(&args.pair).to_string();
287
288    if is_dex_venue(&args.venue) {
289        // DEX path: synthesize from DexScreener analytics
290        let chain = dex_venue_to_chain(&args.venue);
291        let analytics =
292            crawl::fetch_analytics_for_input(&base, chain, Period::Hour24, 10, factory).await?;
293        if analytics.dex_pairs.is_empty() {
294            return Err(ScopeError::Chain(format!(
295                "No DEX pairs found for {} on {}",
296                base, chain
297            )));
298        }
299        let best_pair = analytics
300            .dex_pairs
301            .iter()
302            .max_by(|a, b| {
303                a.liquidity_usd
304                    .partial_cmp(&b.liquidity_usd)
305                    .unwrap_or(std::cmp::Ordering::Equal)
306            })
307            .unwrap();
308        let book = order_book_from_analytics(chain, best_pair, &analytics.token.symbol);
309        let volume = Some(best_pair.volume_24h);
310        Ok((book, volume))
311    } else {
312        // CEX path: use VenueRegistry + ExchangeClient
313        let registry = VenueRegistry::load()?;
314        let exchange = registry.create_exchange_client(&args.venue)?;
315        let pair = exchange.format_pair(&base);
316        let book = exchange.fetch_order_book(&pair).await?;
317
318        // Get volume from ticker if available
319        let volume = if exchange.has_ticker() {
320            exchange
321                .fetch_ticker(&pair)
322                .await
323                .ok()
324                .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
325        } else {
326            None
327        };
328        Ok((book, volume))
329    }
330}
331
332async fn run_summary_once(
333    args: &SummaryArgs,
334    factory: &dyn ChainClientFactory,
335    thresholds: &HealthThresholds,
336    run_num: Option<u64>,
337) -> Result<MarketSummary> {
338    if let Some(n) = run_num {
339        let ts = chrono::Utc::now().format("%H:%M:%S");
340        eprintln!("  --- Run #{} at {} ---\n", n, ts);
341    }
342
343    let (book, volume_24h) = fetch_book_and_volume(args, factory).await?;
344    let summary = MarketSummary::from_order_book(&book, args.peg, thresholds, volume_24h);
345
346    let venue_label = args.venue.clone();
347
348    match args.format {
349        SummaryFormat::Text => {
350            print!("{}", summary.format_text(Some(&venue_label)));
351        }
352        SummaryFormat::Json => {
353            let json = serde_json::json!({
354                "run": run_num,
355                "venue": venue_label,
356                "pair": summary.pair,
357                "peg_target": summary.peg_target,
358                "best_bid": summary.best_bid,
359                "best_ask": summary.best_ask,
360                "mid_price": summary.mid_price,
361                "spread": summary.spread,
362                "volume_24h": summary.volume_24h,
363                "execution_10k_buy": summary.execution_10k_buy.as_ref().map(|e| serde_json::json!({
364                    "fillable": e.fillable,
365                    "slippage_bps": e.slippage_bps
366                })),
367                "execution_10k_sell": summary.execution_10k_sell.as_ref().map(|e| serde_json::json!({
368                    "fillable": e.fillable,
369                    "slippage_bps": e.slippage_bps
370                })),
371                "ask_depth": summary.ask_depth,
372                "bid_depth": summary.bid_depth,
373                "ask_levels": summary.asks.len(),
374                "bid_levels": summary.bids.len(),
375                "healthy": summary.healthy,
376                "checks": summary.checks.iter().map(|c| match c {
377                    crate::market::HealthCheck::Pass(m) => serde_json::json!({"status": "pass", "message": m}),
378                    crate::market::HealthCheck::Fail(m) => serde_json::json!({"status": "fail", "message": m}),
379                }).collect::<Vec<_>>(),
380            });
381            println!("{}", serde_json::to_string_pretty(&json)?);
382        }
383    }
384
385    Ok(summary)
386}
387
388async fn run_summary(args: SummaryArgs, factory: &dyn ChainClientFactory) -> Result<()> {
389    let thresholds = HealthThresholds {
390        peg_target: args.peg,
391        peg_range: args.peg_range,
392        min_levels: args.min_levels,
393        min_depth: args.min_depth,
394        min_bid_ask_ratio: args.min_bid_ask_ratio,
395        max_bid_ask_ratio: args.max_bid_ask_ratio,
396    };
397
398    let repeat_mode = args.every.is_some() || args.duration.is_some();
399
400    if !repeat_mode {
401        let summary = run_summary_once(&args, factory, &thresholds, None).await?;
402        if let Some(ref report_path) = args.report {
403            let venue_label = args.venue.clone();
404            let md = market_summary_to_markdown(&summary, &venue_label, &args.pair);
405            std::fs::write(report_path, md)?;
406            eprintln!("\nReport saved to: {}", report_path.display());
407        }
408        return Ok(());
409    }
410
411    let every_secs = args
412        .every
413        .as_ref()
414        .map(|s| parse_duration(s))
415        .transpose()?
416        .unwrap_or(DEFAULT_EVERY_SECS);
417
418    let duration_secs = args
419        .duration
420        .as_ref()
421        .map(|s| parse_duration(s))
422        .transpose()?
423        .unwrap_or(DEFAULT_DURATION_SECS);
424
425    if every_secs == 0 {
426        return Err(ScopeError::Chain("Interval must be positive".to_string()));
427    }
428
429    let every = Duration::from_secs(every_secs);
430    let start = std::time::Instant::now();
431    let duration = Duration::from_secs(duration_secs);
432
433    eprintln!(
434        "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
435        every_secs, duration_secs
436    );
437
438    let mut run_num: u64 = 1;
439    #[allow(unused_assignments)]
440    let mut last_summary: Option<MarketSummary> = None;
441
442    // Initialize CSV if requested
443    if let Some(ref csv_path) = args.csv {
444        let header =
445            "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy\n";
446        std::fs::write(csv_path, header)?;
447    }
448
449    loop {
450        let summary = run_summary_once(&args, factory, &thresholds, Some(run_num)).await?;
451        last_summary = Some(summary.clone());
452
453        // Append CSV row
454        if let Some(ref csv_path) = args.csv {
455            let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
456            let bid = summary
457                .best_bid
458                .map(|v| v.to_string())
459                .unwrap_or_else(|| "-".to_string());
460            let ask = summary
461                .best_ask
462                .map(|v| v.to_string())
463                .unwrap_or_else(|| "-".to_string());
464            let mid = summary
465                .mid_price
466                .map(|v| v.to_string())
467                .unwrap_or_else(|| "-".to_string());
468            let spread = summary
469                .spread
470                .map(|v| v.to_string())
471                .unwrap_or_else(|| "-".to_string());
472            let row = format!(
473                "{},{},{},{},{},{},{},{},{}\n",
474                ts,
475                run_num,
476                bid,
477                ask,
478                mid,
479                spread,
480                summary.bid_depth,
481                summary.ask_depth,
482                summary.healthy
483            );
484            let mut f = std::fs::OpenOptions::new().append(true).open(csv_path)?;
485            use std::io::Write;
486            f.write_all(row.as_bytes())?;
487        }
488
489        if start.elapsed() >= duration {
490            eprintln!("\nCompleted {} run(s) over {}s.", run_num, duration_secs);
491            break;
492        }
493
494        run_num += 1;
495
496        let remaining = duration.saturating_sub(start.elapsed());
497        let sleep_duration = if remaining < every { remaining } else { every };
498        tokio::time::sleep(sleep_duration).await;
499    }
500
501    // Save final report if requested (last_summary always set when loop runs)
502    if let (Some(ref report_path), Some(summary)) = (args.report, last_summary.as_ref()) {
503        let venue_label = args.venue.clone();
504        let md = market_summary_to_markdown(summary, &venue_label, &args.pair);
505        std::fs::write(report_path, md)?;
506        eprintln!("Report saved to: {}", report_path.display());
507    }
508    if let Some(ref csv_path) = args.csv {
509        eprintln!("Time-series CSV saved to: {}", csv_path.display());
510    }
511
512    Ok(())
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::chains::DefaultClientFactory;
519
520    /// Helper to create a mock venue YAML pointing at the given mock server URL.
521    /// Writes a temporary venue descriptor to the user venues directory so the
522    /// registry picks it up. Returns the venue id.
523    fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
524        let venue_id = format!("test_mock_{}", std::process::id());
525        let yaml = format!(
526            r#"
527id: {venue_id}
528name: Test Mock Venue
529base_url: {server_url}
530timeout_secs: 5
531symbol:
532  template: "{{base}}_{{quote}}"
533  default_quote: USDT
534capabilities:
535  order_book:
536    path: /api/v1/depth
537    params:
538      symbol: "{{pair}}"
539    response:
540      asks_key: asks
541      bids_key: bids
542      level_format: positional
543  ticker:
544    path: /api/v1/ticker
545    params:
546      symbol: "{{pair}}"
547    response:
548      last_price: last
549      volume_24h: vol
550"#
551        );
552        let dir = tempfile::tempdir().unwrap();
553        let file_path = dir.path().join(format!("{}.yaml", venue_id));
554        std::fs::write(&file_path, yaml).unwrap();
555        // We can't easily inject into the registry, so instead
556        // create a ConfigurableExchangeClient directly in tests.
557        (venue_id, dir)
558    }
559
560    #[tokio::test]
561    async fn test_run_summary_with_mock_orderbook() {
562        // This test uses the DEX path since mock HTTP with the registry is complex.
563        // Tested in integration tests and exchange module unit tests instead.
564        // Test duration parsing and summary formatting here.
565        let args = SummaryArgs {
566            pair: "USDC".to_string(),
567            venue: "eth".to_string(),
568            chain: "ethereum".to_string(),
569            peg: 1.0,
570            min_levels: 1,
571            min_depth: 50.0,
572            peg_range: 0.01,
573            min_bid_ask_ratio: 0.1,
574            max_bid_ask_ratio: 10.0,
575            format: SummaryFormat::Text,
576            every: None,
577            duration: None,
578            report: None,
579            csv: None,
580        };
581
582        let factory = DefaultClientFactory {
583            chains_config: Default::default(),
584        };
585        // DEX path: will hit real DexScreener API, may fail in offline environments
586        let _result = run_summary(args, &factory).await;
587        // We don't assert success because it depends on network, just confirm no panic
588    }
589
590    #[tokio::test]
591    async fn test_run_summary_json_format() {
592        let args = SummaryArgs {
593            pair: "USDC".to_string(),
594            venue: "eth".to_string(),
595            chain: "ethereum".to_string(),
596            peg: 1.0,
597            min_levels: 1,
598            min_depth: 50.0,
599            peg_range: 0.01,
600            min_bid_ask_ratio: 0.1,
601            max_bid_ask_ratio: 10.0,
602            format: SummaryFormat::Json,
603            every: None,
604            duration: None,
605            report: None,
606            csv: None,
607        };
608
609        let factory = DefaultClientFactory {
610            chains_config: Default::default(),
611        };
612        let _result = run_summary(args, &factory).await;
613    }
614
615    #[test]
616    fn test_parse_duration_seconds() {
617        assert_eq!(parse_duration("30s").unwrap(), 30);
618        assert_eq!(parse_duration("1").unwrap(), 1);
619        assert_eq!(parse_duration("60sec").unwrap(), 60);
620    }
621
622    #[test]
623    fn test_parse_duration_minutes() {
624        assert_eq!(parse_duration("5m").unwrap(), 300);
625        assert_eq!(parse_duration("1min").unwrap(), 60);
626        assert_eq!(parse_duration("2.5m").unwrap(), 150);
627    }
628
629    #[test]
630    fn test_parse_duration_hours() {
631        assert_eq!(parse_duration("1h").unwrap(), 3600);
632        assert_eq!(parse_duration("24h").unwrap(), 86400);
633    }
634
635    #[test]
636    fn test_parse_duration_invalid() {
637        assert!(parse_duration("").is_err());
638        assert!(parse_duration("abc").is_err());
639        assert!(parse_duration("30x").is_err());
640    }
641
642    #[test]
643    fn test_parse_duration_non_positive() {
644        assert!(parse_duration("0").is_err());
645        assert!(parse_duration("-5s").is_err());
646    }
647
648    // ====================================================================
649    // base_symbol_from_pair tests
650    // ====================================================================
651
652    #[test]
653    fn test_base_symbol_from_pair_underscore() {
654        assert_eq!(base_symbol_from_pair("PUSD_USDT"), "PUSD");
655        assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
656    }
657
658    #[test]
659    fn test_base_symbol_from_pair_lowercase_underscore() {
660        assert_eq!(base_symbol_from_pair("pusd_usdt"), "pusd");
661    }
662
663    #[test]
664    fn test_base_symbol_from_pair_slash() {
665        assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
666    }
667
668    #[test]
669    fn test_base_symbol_from_pair_concat() {
670        assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
671        assert_eq!(base_symbol_from_pair("PUSDUSDT"), "PUSD");
672    }
673
674    #[test]
675    fn test_base_symbol_from_pair_plain() {
676        assert_eq!(base_symbol_from_pair("USDC"), "USDC");
677        assert_eq!(base_symbol_from_pair("ETH"), "ETH");
678    }
679
680    #[test]
681    fn test_base_symbol_from_pair_whitespace() {
682        assert_eq!(base_symbol_from_pair("  PUSD_USDT  "), "PUSD");
683    }
684
685    // ====================================================================
686    // market_summary_to_markdown tests
687    // ====================================================================
688
689    #[test]
690    fn test_market_summary_to_markdown_basic() {
691        use crate::market::{HealthCheck, MarketSummary};
692        let summary = MarketSummary {
693            pair: "USDCUSDT".to_string(),
694            peg_target: 1.0,
695            best_bid: Some(0.9999),
696            best_ask: Some(1.0001),
697            mid_price: Some(1.0000),
698            spread: Some(0.0002),
699            volume_24h: Some(1_000_000.0),
700            bid_depth: 50_000.0,
701            ask_depth: 50_000.0,
702            bid_outliers: 0,
703            ask_outliers: 0,
704            healthy: true,
705            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
706            execution_10k_buy: None,
707            execution_10k_sell: None,
708            asks: vec![],
709            bids: vec![],
710        };
711        let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
712        assert!(md.contains("Market Health Report"));
713        assert!(md.contains("USDCUSDT"));
714        assert!(md.contains("Binance"));
715        assert!(md.contains("Peg Target"));
716        assert!(md.contains("1.0000"));
717        assert!(md.contains("Healthy"));
718    }
719
720    #[test]
721    fn test_market_summary_to_markdown_no_prices() {
722        use crate::market::{HealthCheck, MarketSummary};
723        let summary = MarketSummary {
724            pair: "TESTUSDT".to_string(),
725            peg_target: 1.0,
726            best_bid: None,
727            best_ask: None,
728            mid_price: None,
729            spread: None,
730            volume_24h: None,
731            bid_depth: 0.0,
732            ask_depth: 0.0,
733            bid_outliers: 0,
734            ask_outliers: 0,
735            healthy: false,
736            checks: vec![HealthCheck::Fail("No data".to_string())],
737            execution_10k_buy: None,
738            execution_10k_sell: None,
739            asks: vec![],
740            bids: vec![],
741        };
742        let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
743        assert!(md.contains("Market Health Report"));
744        assert!(md.contains("-")); // missing data shown as "-"
745    }
746
747    // ====================================================================
748    // parse_duration — additional edge cases
749    // ====================================================================
750
751    #[test]
752    fn test_parse_duration_days() {
753        assert_eq!(parse_duration("1d").unwrap(), 86400);
754        assert_eq!(parse_duration("7d").unwrap(), 604800);
755        assert_eq!(parse_duration("1day").unwrap(), 86400);
756        assert_eq!(parse_duration("2days").unwrap(), 172800);
757    }
758
759    #[test]
760    fn test_parse_duration_long_names() {
761        assert_eq!(parse_duration("30seconds").unwrap(), 30);
762        assert_eq!(parse_duration("5minutes").unwrap(), 300);
763        assert_eq!(parse_duration("2hours").unwrap(), 7200);
764    }
765
766    #[test]
767    fn test_parse_duration_fractional() {
768        assert_eq!(parse_duration("0.5h").unwrap(), 1800);
769        assert_eq!(parse_duration("1.5m").unwrap(), 90);
770    }
771
772    // ====================================================================
773    // SummaryFormat tests
774    // ====================================================================
775
776    #[test]
777    fn test_summary_format_default() {
778        let fmt = SummaryFormat::default();
779        assert!(matches!(fmt, SummaryFormat::Text));
780    }
781
782    #[test]
783    fn test_summary_format_debug() {
784        let text = format!("{:?}", SummaryFormat::Text);
785        assert_eq!(text, "Text");
786        let json = format!("{:?}", SummaryFormat::Json);
787        assert_eq!(json, "Json");
788    }
789
790    // ====================================================================
791    // MarketCommands parsing tests
792    // ====================================================================
793
794    #[test]
795    fn test_summary_args_debug() {
796        let args = SummaryArgs {
797            pair: "USDC".to_string(),
798            venue: "binance".to_string(),
799            chain: "ethereum".to_string(),
800            peg: 1.0,
801            min_levels: 6,
802            min_depth: 3000.0,
803            peg_range: 0.001,
804            min_bid_ask_ratio: 0.2,
805            max_bid_ask_ratio: 5.0,
806            format: SummaryFormat::Text,
807            every: None,
808            duration: None,
809            report: None,
810            csv: None,
811        };
812        let debug = format!("{:?}", args);
813        assert!(debug.contains("SummaryArgs"));
814        assert!(debug.contains("USDC"));
815    }
816
817    #[test]
818    fn test_default_constants() {
819        assert_eq!(DEFAULT_EVERY_SECS, 60);
820        assert_eq!(DEFAULT_DURATION_SECS, 3600);
821    }
822
823    #[test]
824    fn test_market_summary_to_markdown_with_execution_estimates() {
825        use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
826        let summary = MarketSummary {
827            pair: "TESTUSDT".to_string(),
828            peg_target: 1.0,
829            best_bid: Some(0.9999),
830            best_ask: Some(1.0001),
831            mid_price: Some(1.0000),
832            spread: Some(0.0002),
833            volume_24h: Some(1_000_000.0),
834            bid_depth: 50_000.0,
835            ask_depth: 50_000.0,
836            bid_outliers: 0,
837            ask_outliers: 0,
838            healthy: true,
839            checks: vec![HealthCheck::Pass("Spread within range".to_string())],
840            execution_10k_buy: Some(ExecutionEstimate {
841                notional_usdt: 10_000.0,
842                side: ExecutionSide::Buy,
843                vwap: 1.0001,
844                slippage_bps: 1.5,
845                fillable: true,
846            }),
847            execution_10k_sell: Some(ExecutionEstimate {
848                notional_usdt: 10_000.0,
849                side: ExecutionSide::Sell,
850                vwap: 0.0,
851                slippage_bps: 0.0,
852                fillable: false,
853            }),
854            asks: vec![],
855            bids: vec![],
856        };
857        let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
858        assert!(md.contains("Market Health Report"));
859        assert!(md.contains("TESTUSDT"));
860        assert!(md.contains("TestVenue"));
861        // Check for fillable buy slippage (should show "1.50 bps")
862        assert!(md.contains("1.50 bps"));
863        // Check for unfillable sell (should show "insufficient")
864        assert!(md.contains("insufficient"));
865    }
866
867    #[tokio::test]
868    async fn test_run_with_summary_command() {
869        // Test the run() dispatcher with a DEX venue (doesn't require mock HTTP)
870        let args = MarketCommands::Summary(SummaryArgs {
871            pair: "USDC".to_string(),
872            venue: "eth".to_string(),
873            chain: "ethereum".to_string(),
874            peg: 1.0,
875            min_levels: 1,
876            min_depth: 50.0,
877            peg_range: 0.01,
878            min_bid_ask_ratio: 0.1,
879            max_bid_ask_ratio: 10.0,
880            format: SummaryFormat::Text,
881            every: None,
882            duration: None,
883            report: None,
884            csv: None,
885        });
886
887        let factory = DefaultClientFactory {
888            chains_config: Default::default(),
889        };
890        let config = Config::default();
891        let _result = run(args, &config, &factory).await;
892        // Don't assert success - depends on network
893    }
894
895    #[test]
896    fn test_is_dex_venue() {
897        assert!(is_dex_venue("eth"));
898        assert!(is_dex_venue("ethereum"));
899        assert!(is_dex_venue("Ethereum"));
900        assert!(is_dex_venue("solana"));
901        assert!(is_dex_venue("Solana"));
902        assert!(!is_dex_venue("binance"));
903        assert!(!is_dex_venue("okx"));
904        assert!(!is_dex_venue("mexc"));
905    }
906
907    #[test]
908    fn test_dex_venue_to_chain() {
909        assert_eq!(dex_venue_to_chain("eth"), "ethereum");
910        assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
911        assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
912        assert_eq!(dex_venue_to_chain("solana"), "solana");
913    }
914
915    #[test]
916    fn test_venue_registry_loaded_in_cex_path() {
917        // Verify the registry loads and can create an exchange client for any built-in venue
918        let registry = VenueRegistry::load().unwrap();
919        assert!(registry.contains("binance"));
920        let client = registry.create_exchange_client("binance");
921        assert!(client.is_ok());
922    }
923
924    #[test]
925    fn test_venue_registry_error_for_unknown() {
926        let registry = VenueRegistry::load().unwrap();
927        let result = registry.create_exchange_client("kracken");
928        assert!(result.is_err());
929        let err = result.unwrap_err().to_string();
930        assert!(err.contains("Unknown venue"));
931        assert!(err.contains("Did you mean")); // should suggest kraken (distance 1)
932    }
933
934    #[tokio::test]
935    async fn test_run_summary_json_format_with_mock() {
936        let args = SummaryArgs {
937            pair: "USDC".to_string(),
938            venue: "eth".to_string(),
939            chain: "ethereum".to_string(),
940            peg: 1.0,
941            min_levels: 1,
942            min_depth: 50.0,
943            peg_range: 0.01,
944            min_bid_ask_ratio: 0.1,
945            max_bid_ask_ratio: 10.0,
946            format: SummaryFormat::Json,
947            every: None,
948            duration: None,
949            report: None,
950            csv: None,
951        };
952
953        let factory = DefaultClientFactory {
954            chains_config: Default::default(),
955        };
956        let _result = run_summary(args, &factory).await;
957    }
958}