1use 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
19pub const DEFAULT_EVERY_SECS: u64 = 60;
21
22pub const DEFAULT_DURATION_SECS: u64 = 3600;
24
25#[derive(Debug, Subcommand)]
27pub enum MarketCommands {
28 Summary(SummaryArgs),
35}
36
37#[derive(Debug, Args)]
42pub struct SummaryArgs {
43 #[arg(default_value = "USDC", value_name = "SYMBOL")]
45 pub pair: String,
46
47 #[arg(long, default_value = "binance", value_name = "VENUE")]
49 pub market_venue: MarketVenue,
50
51 #[arg(long, default_value = "ethereum", value_name = "CHAIN")]
53 pub chain: String,
54
55 #[arg(long, value_name = "URL")]
57 pub biconomy_api_url: Option<String>,
58
59 #[arg(long, default_value = "1.0", value_name = "TARGET")]
61 pub peg: f64,
62
63 #[arg(long, default_value = "6", value_name = "N")]
65 pub min_levels: usize,
66
67 #[arg(long, default_value = "3000", value_name = "USDT")]
69 pub min_depth: f64,
70
71 #[arg(long, default_value = "0.001", value_name = "RANGE")]
74 pub peg_range: f64,
75
76 #[arg(long, default_value = "0.2", value_name = "RATIO")]
78 pub min_bid_ask_ratio: f64,
79
80 #[arg(long, default_value = "5.0", value_name = "RATIO")]
82 pub max_bid_ask_ratio: f64,
83
84 #[arg(short, long, default_value = "text")]
86 pub format: SummaryFormat,
87
88 #[arg(long, value_name = "INTERVAL")]
91 pub every: Option<String>,
92
93 #[arg(long, value_name = "DURATION")]
96 pub duration: Option<String>,
97
98 #[arg(long, value_name = "PATH")]
100 pub report: Option<std::path::PathBuf>,
101
102 #[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 #[default]
111 Text,
112 Json,
114}
115
116pub 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
127fn 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
181fn 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 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 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 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}