Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 21 additions & 86 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# CryptoScope 🔍

**Multi-exchange crypto symbols intelligence tool**
Fetch and analyze perpetual/derivative symbols from crypto exchanges with a clean, modular TUI interface.

Fetch and analyze perpetual/derivative symbols from crypto exchanges with a clean, modular CLI interface.
---

## Features

- ✅ Fetch all perpetual and derivative symbols from Bybit V5 API
- ✅ Fetch all perpetual and derivative symbols from Exchange
- ✅ Support for both linear (USDT) and inverse categories
- ✅ Automatic pagination handling
- ✅ Filter by symbol name or status
Expand All @@ -26,12 +27,14 @@ cargo build --release
cargo install --path .
```

---

## Usage

### Basic Usage

```bash
# Fetch all symbols from Bybit (linear + inverse)
# Launch interactive TUI
cryptoscope

# Fetch only linear (USDT perpetual) symbols
Expand All @@ -44,16 +47,20 @@ cryptoscope --category inverse
### Output Formats

```bash
# Human-readable text output (default)
# Interactive terminal UI (TUI)
cryptoscope

# Human-readable text output
cryptoscope --output text
# or use the convenience flag
cryptoscope --cli

# Machine-readable JSON output
cryptoscope --output json > symbols.json

# Interactive terminal UI (TUI)
cryptoscope --output tui
```

**Note:** `--cli` is a shorthand for `--output text`. These flags conflict with each other.

### Filtering

```bash
Expand All @@ -74,6 +81,8 @@ cryptoscope --verbose
cryptoscope --help
```

---

## Example Output

### Text Output
Expand Down Expand Up @@ -106,11 +115,7 @@ Categories: linear, inverse

### TUI Output

Launch the interactive terminal UI:

```bash
cryptoscope --output tui
```
![TUI](docs/image/TUI.png)

The TUI features:
- **Symbol table** - Scrollable list with selection highlighting
Expand All @@ -130,28 +135,7 @@ The TUI features:
| `Tab` | Toggle symbol list / stats view |
| `r` | Refresh data |

## Architecture

CryptoScope uses a trait-based architecture for easy extensibility:

```
┌─────────────────────────────────────┐
│ CLI Layer │
│ (main.rs + cli.rs) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Exchange Trait │
│ (exchange/exchange_trait.rs) │
└─────────────────────────────────────┘
▲ ▲
│ │
┌──────┴──────┐ ┌───────┴────────┐
│ BybitClient │ │ BinanceClient │ (future)
│ (v1.0) │ │ (v2.0) │
└─────────────┘ └────────────────┘
```
---

### Adding a New Exchange

Expand All @@ -163,58 +147,7 @@ To add support for a new exchange (e.g., Binance):

That's it! No changes to existing code required.

## Project Structure

```
cryptoscope/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point
│ ├── cli.rs # CLI argument parsing
│ ├── error.rs # Error types
│ ├── models/
│ │ ├── mod.rs
│ │ ├── symbol.rs # Symbol struct
│ │ ├── response.rs # API responses
│ │ └── statistics.rs # Statistics aggregation
│ ├── exchange/
│ │ ├── mod.rs
│ │ ├── exchange_trait.rs # Exchange trait
│ │ ├── bybit.rs # Bybit implementation
│ │ └── factory.rs # Exchange factory
│ ├── fetcher/
│ │ ├── mod.rs
│ │ └── instrument_fetcher.rs
│ ├── output/
│ │ ├── mod.rs
│ │ ├── formatter.rs # Text output
│ │ └── json_output.rs # JSON output
│ └── tui/
│ ├── mod.rs
│ ├── app.rs # App state management
│ ├── runner.rs # TUI event loop
│ ├── theme.rs # Cyberpunk color theme
│ └── widgets/
│ ├── mod.rs
│ ├── header.rs # Header widget
│ ├── footer.rs # Footer widget
│ ├── popup.rs # Popup/notification widget
│ ├── stats_panel.rs # Stats dashboard widget
│ └── symbol_table.rs # Symbol table widget
└── tests/
```

## Tech Stack

- **tokio** - Async runtime
- **reqwest** - HTTP client
- **serde + serde_json** - JSON serialization
- **clap** - CLI framework
- **thiserror + anyhow** - Error handling
- **tracing** - Logging
- **ratatui** - Terminal UI framework
- **crossterm** - Terminal manipulation
- **unicode-width** - Unicode string width calculation
---

## Current Status

Expand All @@ -228,6 +161,8 @@ cryptoscope/
- ⏳ OKX Derivatives
- ⏳ Symbol comparison across exchanges

---

## License

GNU General Public License v3.0 (GPL-3.0)
Expand Down
Binary file added docs/image/TUI.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 65 additions & 4 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use clap::{Parser, ValueEnum};

#[derive(Debug, Clone, Default, ValueEnum)]
#[derive(Debug, Clone, Copy, Default, PartialEq, ValueEnum)]
pub enum OutputMode {
#[default]
Text,
Json,
#[default]
Tui,
}

Expand All @@ -25,9 +25,13 @@ pub struct Cli {
pub category: String,

/// Output format: text, json, or tui
#[arg(short, long, default_value = "text")]
#[arg(short, long, default_value = "tui")]
pub output: OutputMode,

/// Use CLI text output instead of TUI (shorthand for --output text)
#[arg(long, conflicts_with = "output")]
pub cli: bool,

/// Search symbols by name (case-insensitive)
#[arg(long)]
pub search: Option<String>,
Expand Down Expand Up @@ -57,6 +61,17 @@ impl Cli {
_ => vec!["linear", "inverse"],
}
}

/// Get the effective output mode
///
/// Returns Text if --cli flag is set, otherwise returns the configured output mode.
pub fn get_output_mode(&self) -> OutputMode {
if self.cli {
OutputMode::Text
} else {
self.output
}
}
}

#[cfg(test)]
Expand All @@ -68,7 +83,7 @@ mod tests {
let cli = Cli::parse_from(["cryptoscope"]);
assert_eq!(cli.exchange, "bybit");
assert_eq!(cli.category, "all");
assert!(matches!(cli.output, OutputMode::Text));
assert!(matches!(cli.output, OutputMode::Tui));
}

#[test]
Expand All @@ -79,4 +94,50 @@ mod tests {
let cli_linear = Cli::parse_from(["cryptoscope", "--category", "linear"]);
assert_eq!(cli_linear.get_categories(), vec!["linear"]);
}

#[test]
fn test_cli_flag() {
// Test --cli flag sets output to Text
let cli = Cli::parse_from(["cryptoscope", "--cli"]);
assert!(cli.cli);
assert_eq!(cli.get_output_mode(), OutputMode::Text);

// Test default (no --cli) uses configured output (tui by default)
let cli_default = Cli::parse_from(["cryptoscope"]);
assert!(!cli_default.cli);
assert_eq!(cli_default.get_output_mode(), OutputMode::Tui);

// Test --output text still works
let cli_text = Cli::parse_from(["cryptoscope", "--output", "text"]);
assert!(!cli_text.cli);
assert_eq!(cli_text.get_output_mode(), OutputMode::Text);
}

#[test]
fn test_cli_with_search() {
// Test --cli combined with --search
let cli = Cli::parse_from(["cryptoscope", "--cli", "--search", "BTC"]);
assert!(cli.cli);
assert_eq!(cli.get_output_mode(), OutputMode::Text);
assert_eq!(cli.search, Some("BTC".to_string()));
}

#[test]
fn test_cli_with_verbose() {
// Test --cli combined with --verbose
let cli = Cli::parse_from(["cryptoscope", "--cli", "--verbose"]);
assert!(cli.cli);
assert!(cli.verbose);
assert_eq!(cli.get_output_mode(), OutputMode::Text);
}

#[test]
fn test_cli_with_category() {
// Test --cli combined with --category
let cli = Cli::parse_from(["cryptoscope", "--cli", "--category", "linear"]);
assert!(cli.cli);
assert_eq!(cli.category, "linear");
assert_eq!(cli.get_categories(), vec!["linear"]);
assert_eq!(cli.get_output_mode(), OutputMode::Text);
}
}
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async fn run(cli: Cli) -> Result<()> {
let elapsed = start_time.elapsed();

// Output results
match cli.output {
match cli.get_output_mode() {
OutputMode::Json => {
print_json(&cli.exchange, &categories, &filtered_symbols, &stats)?;
}
Expand Down Expand Up @@ -110,7 +110,7 @@ async fn main() -> Result<()> {
"Exchange: {}, Category: {:?}, Output: {:?}",
cli.exchange,
cli.get_categories(),
cli.output
cli.get_output_mode()
);

run(cli).await
Expand Down
60 changes: 59 additions & 1 deletion src/tui/app.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use crate::models::Statistics;
use crate::models::symbol::Symbol;
use crate::output::SymbolFilter;
use crate::tui::mouse::{ClickAction, HeaderTab, ScrollDirection};
use ratatui::widgets::TableState;
use std::time::Instant;

/// Represents the current view mode of the TUI application.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppView {
SymbolList,
StatsDashboard,
Expand Down Expand Up @@ -182,4 +183,61 @@ impl AppState {
self.popup_message = None;
self.popup_timer = None;
}

/// Handle scroll up action (scroll wheel up or click on scroll up button).
pub fn on_scroll_up(&mut self) {
self.select_prev();
}

/// Handle scroll down action (scroll wheel down or click on scroll down button).
pub fn on_scroll_down(&mut self) {
self.select_next();
}

/// Handle click on a table row at the given index.
pub fn on_table_click(&mut self, row_index: usize) {
if row_index < self.filtered.len() {
self.table_state.select(Some(row_index));
}
}

/// Handle click on a header tab.
pub fn on_header_tab_click(&mut self, tab: HeaderTab) {
self.view = match tab {
HeaderTab::SymbolList => AppView::SymbolList,
HeaderTab::StatsDashboard => AppView::StatsDashboard,
};
}

/// Handle click on scrollbar track for page up/down.
pub fn on_scrollbar_track_click(&mut self, direction: ScrollDirection) {
if self.filtered.is_empty() {
return;
}
// Jump 20% of list or 10 rows, whichever is larger
let jump = 10.max(self.filtered.len() / 5);
match direction {
ScrollDirection::Up => {
let current = self.table_state.selected().unwrap_or(0);
let new_pos = current.saturating_sub(jump);
self.table_state.select(Some(new_pos));
}
ScrollDirection::Down => {
let current = self.table_state.selected().unwrap_or(0);
let new_pos = (current + jump).min(self.filtered.len().saturating_sub(1));
self.table_state.select(Some(new_pos));
}
}
}

/// Handle a mouse click action.
pub fn on_mouse_click(&mut self, action: &ClickAction) {
match action {
ClickAction::ScrollUp => self.on_scroll_up(),
ClickAction::ScrollDown => self.on_scroll_down(),
ClickAction::TableRow(index) => self.on_table_click(*index),
ClickAction::HeaderTab(tab) => self.on_header_tab_click(*tab),
ClickAction::ScrollbarTrack(direction) => self.on_scrollbar_track_click(*direction),
}
}
}
1 change: 1 addition & 0 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod app;
pub mod mouse;
pub mod runner;
pub mod theme;
pub mod widgets;
Expand Down
Loading