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