From 650879a0684248955b9ccf711808dd3a4cda11b7 Mon Sep 17 00:00:00 2001 From: Zachary Roth Date: Tue, 3 Mar 2026 12:57:40 -0800 Subject: [PATCH 1/6] docs: TUI dashboard redesign design doc Full-terminal status dashboard with exchanges, credentials, funding opportunities, and account overview panels. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-03-tui-redesign-design.md | 193 +++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/plans/2026-03-03-tui-redesign-design.md diff --git a/docs/plans/2026-03-03-tui-redesign-design.md b/docs/plans/2026-03-03-tui-redesign-design.md new file mode 100644 index 0000000..3b13416 --- /dev/null +++ b/docs/plans/2026-03-03-tui-redesign-design.md @@ -0,0 +1,193 @@ +# TUI Dashboard Redesign + +**Date:** 2026-03-03 +**Status:** Approved + +## Purpose + +Redesign the perps TUI from scratch as a **read-only status dashboard** that fills the full terminal with properly proportioned panels. Not a trading interface — a system overview for operators and agents. + +## What It Shows + +1. **Exchange health** — which connectors are up, their tier, latency, circuit breaker state +2. **Credentials & trading readiness** — which env vars are loaded, which venues are trade-ready, API rate limit budget +3. **Funding opportunities** — spread table with APR, signal, short/long venues +4. **Account overview** — per-venue equity, unrealized P&L, margin utilization +5. **Summary stats** — opportunity count, venue count, best/median APR, tradeable venues + +## Layout + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ RAINTREE PERPS 8/8 up · 47 opps 03:42 PM │ +├──────────────────────────────────┬───────────────────────────────────────────┤ +│ EXCHANGES │ FUNDING OPPORTUNITIES / search │ +│ │ │ +│ Exchange ● Tier Ping CB │ Market Short Long APR Sig │ +│ Hyperliquid OK A 12ms ✓ │ ETH/USD Hyper Dydx +8.4% BUY │ +│ Dydx OK A 45ms ✓ │ BTC/USD Bybit Hyper +6.2% BUY │ +│ Bybit OK B 120ms ✓ │ SOL/USD Dydx Drift +5.1% BUY │ +│ Aevo OK B 89ms ✓ │ ARB/USD Aevo Hyper +3.8% — │ +│ ... │ ... │ +│ │ │ +├──────────────────────────────────┤ │ +│ CREDENTIALS & TRADING │ │ +│ │ │ +│ Exchange Keys Trade Rate │ │ +│ Hyperliquid ✓ ✓ A 80% │ │ +│ Dydx ✓ ✓ A 95% ├──────────────────────────────────────────│ +│ Bybit ✓ ✓ B 70% │ ACCOUNT │ +│ Aevo ✗ ✗ 45% │ │ +│ ... │ Venue Equity Unrl P&L Margin │ +│ │ Hyperliquid $12,450 +$340 23% │ +├──────────────────────────────────│ Dydx $8,200 -$120 45% │ +│ STATS │ Bybit $5,100 +$89 12% │ +│ 47 opps · 12 venues · 25 assets│ │ +│ best +8.4% · median +2.1% │ Total $25,750 +$309 27% │ +│ 3 tradeable · avg ping 59ms │ │ +├──────────────────────────────────┴──────────────────────────────────────────┤ +│ q quit · / search · esc clear cycle 42 │ +└────────────────────────────────────────────────────────────────────────────-┘ +``` + +### Grid Proportions + +- **Header:** 1 line — title left, status chips center, clock right +- **Left column:** 40% width + - Exchanges table: ~45% of left height + - Credentials table: ~35% of left height + - Stats: ~20% of left height +- **Right column:** 60% width + - Funding table: ~65% of right height + - Account table: ~35% of right height +- **Footer:** 1 line — keybindings left, cycle counter right + +### Panels + +#### 1. Header (1 line) +- Left: "RAINTREE PERPS" in cyan bold +- Center: "{n}/{total} up · {opps} opps" — condensed status chips +- Right: current time HH:MM PM + +#### 2. Exchanges Table (left-top) +Columns: Exchange, Health (● OK/DOWN), Tier (A/B/C), Ping (ms), CB (circuit breaker ✓/✗) + +Data source: `connector_health()` + `connector_scorecard()` + `ops_circuit_breakers()` + +Colors: +- OK = green, DOWN = red +- Tier A = green, B = yellow, C = dim white +- Ping: green <100ms, yellow <300ms, red ≥300ms +- CB closed = green ✓, open = red ✗ + +#### 3. Credentials & Trading Table (left-middle) +Columns: Exchange, Keys (✓/✗), Trade (✓ + tier / ✗), Rate (% budget remaining) + +Data source: `ops_auth_check()` + `connector_scorecard()` (execution_enabled) + `ops_rate_limits()` + +Logic: +- Keys: check if required env vars are set (non-empty) +- Trade: execution_enabled from scorecard, show tier if yes +- Rate: remaining request budget as percentage, color-coded (green >50%, yellow >20%, red ≤20%) + +#### 4. Stats Panel (left-bottom) +3 lines of condensed summary text: +- Line 1: "{n} opps · {v} venues · {a} assets" +- Line 2: "best +{x}% · median +{y}%" +- Line 3: "{t} tradeable · avg ping {p}ms" + +#### 5. Funding Opportunities Table (right-top) +Columns: Market, Short (venue), Long (venue), APR (%), Signal + +Data source: `funding_opportunities()` via existing spread scanner + +Colors: +- APR >5% = green bold, >1% = yellow, >0% = white, ≤0% = dim +- Signal BUY = green, SELL = red, — = dim + +Search: filters by market/venue, same as current implementation. + +#### 6. Account Table (right-bottom) +Columns: Venue, Equity ($), Unrealized P&L ($), Margin (%) + +Data source: `account_state()` for each authenticated connector + +Only shows venues where credentials are loaded. Bottom row = "Total" with summed equity/P&L and weighted avg margin. + +Colors: +- P&L positive = green, negative = red +- Margin >80% = red, >50% = yellow, else green + +#### 7. Footer (1 line) +- Left: "q quit · / search · esc clear" +- Right: "cycle {n}" + +## Design Principles (HIG-informed) + +1. **Content over chrome** — Minimal borders (single-line box drawing). Section headers are the primary visual anchors, not decorative frames. +2. **Clear hierarchy** — Three levels: title bar (cyan bold) > section headers (white bold) > data (standard weight). Labels in DarkGray. +3. **Semantic color** — Green/red/yellow carry consistent meaning across all panels. No decorative color. +4. **Full terminal** — Layout expands to fill available space. Tables grow with terminal height. Columns adapt proportionally. +5. **Information density** — Every cell earns its space. No padding panels or decorative elements. + +## Color Palette (unchanged) + +| Color | Semantic meaning | +|-------|-----------------| +| Cyan | Headers, title, search mode indicator | +| Green | Healthy, set, positive, tradeable, good latency | +| Yellow | Degraded, medium, caution | +| Red | Down, missing, negative, blocked, high latency | +| White | Standard data text | +| DarkGray | Borders, labels, separators, neutral/dim values | +| Bold | Emphasis on values (APR, status, tier letters) | + +## Data Refresh Rates + +| Data | Interval | Source | +|------|----------|--------| +| Connector health + scorecard | 5s | `connector_health()` + `connector_scorecard()` | +| Circuit breakers | 5s | `ops_circuit_breakers()` (piggyback on health poll) | +| Auth/credential status | 30s | `ops_auth_check()` | +| Rate limits | 5s | `ops_rate_limits()` (piggyback on health poll) | +| Funding spreads | 15s | Existing spread scanner | +| Account state | 10s | `account_state()` per authenticated connector | +| UI render | 200ms | Event loop tick | + +## Interaction + +- **`/` or typing** — Enters search mode, filters funding table +- **Backspace** — Remove last search character +- **Esc** — Clear search +- **`q`** — Quit (only when not searching) +- No pane selection, no scrolling, no tabs — pure dashboard + +## Architecture + +Rewrite `lib.rs` from scratch. Split into modules for maintainability: + +``` +perps-tui/src/ + lib.rs — public run() entry point, bootstrap, event loop + state.rs — DashState struct, watch channel receivers + layout.rs — constraint calculations, grid proportions + widgets/ + mod.rs + header.rs — title bar + status chips + exchanges.rs — connector health table + credentials.rs — auth + trading readiness table + funding.rs — funding opportunities table + account.rs — account overview table + stats.rs — summary stats panel + footer.rs — keybindings + cycle counter + pollers.rs — background data fetchers (health, funding, account, auth) + style.rs — color constants, shared style helpers +``` + +## Non-Goals + +- No trading from the TUI +- No position management +- No order placement or cancellation +- No interactive pane selection or scrolling +- No configuration changes from the TUI From 5efca21446db93da9727a78010fcedf92eebfdb5 Mon Sep 17 00:00:00 2001 From: Zachary Roth Date: Tue, 3 Mar 2026 13:01:36 -0800 Subject: [PATCH 2/6] docs: TUI redesign implementation plan (11 tasks) Modular rewrite with 6-panel dashboard: exchanges, credentials, funding, account, stats, header/footer. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-03-tui-redesign-plan.md | 1535 ++++++++++++++++++++ 1 file changed, 1535 insertions(+) create mode 100644 docs/plans/2026-03-03-tui-redesign-plan.md diff --git a/docs/plans/2026-03-03-tui-redesign-plan.md b/docs/plans/2026-03-03-tui-redesign-plan.md new file mode 100644 index 0000000..d020a2c --- /dev/null +++ b/docs/plans/2026-03-03-tui-redesign-plan.md @@ -0,0 +1,1535 @@ +# TUI Dashboard Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rewrite the perps TUI from scratch as a full-terminal status dashboard with 6 panels: exchanges, credentials, stats, funding opportunities, account overview, and a compact header/footer. + +**Architecture:** Delete the existing single-file TUI (`crates/perps-tui/src/lib.rs`) and replace it with a modular structure. Each panel is its own widget module. State is centralized in `state.rs`. Background pollers feed data through tokio watch channels. The render loop ticks every 200ms. + +**Tech Stack:** Rust, Ratatui 0.29, Crossterm 0.28, Tokio watch channels. Same crate dependencies as before plus `perps-domain` for `AccountState`/`BalanceSnapshot`/`EnrichedPosition`. + +--- + +### Task 1: Scaffold module structure and style constants + +**Files:** +- Create: `crates/perps-tui/src/style.rs` +- Create: `crates/perps-tui/src/state.rs` +- Create: `crates/perps-tui/src/layout.rs` +- Create: `crates/perps-tui/src/widgets/mod.rs` +- Modify: `crates/perps-tui/src/lib.rs` (gut the file, re-export `run()`) + +**Step 1: Create `style.rs` with color constants and shared helpers** + +```rust +use ratatui::style::{Color, Modifier, Style}; + +// ── Semantic colors (HIG-informed) ────────────────────────────────── + +pub const TITLE: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD); +pub const HEADER: Style = Style::new().fg(Color::White).add_modifier(Modifier::BOLD); +pub const LABEL: Style = Style::new().fg(Color::DarkGray); +pub const BORDER: Style = Style::new().fg(Color::DarkGray); +pub const VALUE: Style = Style::new().fg(Color::White); +pub const VALUE_BOLD: Style = Style::new().fg(Color::White).add_modifier(Modifier::BOLD); +pub const GOOD: Style = Style::new().fg(Color::Green); +pub const GOOD_BOLD: Style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD); +pub const WARN: Style = Style::new().fg(Color::Yellow); +pub const WARN_BOLD: Style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); +pub const BAD: Style = Style::new().fg(Color::Red); +pub const BAD_BOLD: Style = Style::new().fg(Color::Red).add_modifier(Modifier::BOLD); +pub const DIM: Style = Style::new().fg(Color::DarkGray); +pub const CYAN: Style = Style::new().fg(Color::Cyan); + +pub fn health_color(ok: bool) -> Style { + if ok { GOOD_BOLD } else { BAD_BOLD } +} + +pub fn tier_style(tier: &perps_app::ConnectorTier) -> (& 'static str, Style) { + match tier { + perps_app::ConnectorTier::A => ("A", GOOD_BOLD), + perps_app::ConnectorTier::B => ("B", WARN_BOLD), + perps_app::ConnectorTier::C => ("C", DIM), + } +} + +pub fn latency_style(ms: u64) -> Style { + if ms < 100 { GOOD } else if ms < 300 { WARN } else { BAD } +} + +pub fn apr_style(pct: f64) -> Style { + if pct > 5.0 { GOOD_BOLD } else if pct > 1.0 { WARN } else if pct > 0.0 { VALUE } else { DIM } +} + +pub fn pnl_style(val: f64) -> Style { + if val > 0.0 { GOOD } else if val < 0.0 { BAD } else { DIM } +} + +pub fn margin_style(pct: f64) -> Style { + if pct > 80.0 { BAD } else if pct > 50.0 { WARN } else { GOOD } +} + +pub fn rate_budget_style(pct: f64) -> Style { + if pct > 50.0 { GOOD } else if pct > 20.0 { WARN } else { BAD } +} +``` + +**Step 2: Create `state.rs` with DashState** + +```rust +use std::time::Instant; +use perps_app::ConnectorScorecard; +use perps_connector_api::ConnectorHealth; +use perps_domain::{AccountState, FundingNormalized}; +use perps_funding::FundingSpreadOpportunity; + +/// All data the dashboard reads each frame — populated by watch channels. +pub struct DashState { + pub health: Vec, + pub scorecard: Vec, + pub spreads: Vec, + pub funding_raw: Vec, + pub account: Option, + pub auth_status: Vec, + pub circuit_breakers: Vec, + pub rate_limits: Vec, + pub last_health_refresh: Option, + pub last_funding_refresh: Option, + pub last_account_refresh: Option, + pub funding_assets_scanned: usize, + pub funding_assets_total: usize, + pub search_query: String, + pub cycle: u64, +} +``` + +**Step 3: Create `layout.rs` with grid proportions** + +```rust +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// Top-level: header (1 line) + body (fill) + footer (1 line) +pub fn main_layout(area: Rect) -> (Rect, Rect, Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // header + Constraint::Min(10), // body + Constraint::Length(1), // footer + ]) + .split(area); + (chunks[0], chunks[1], chunks[2]) +} + +/// Body: left column (40%) + right column (60%) +pub fn body_columns(area: Rect) -> (Rect, Rect) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(area); + (cols[0], cols[1]) +} + +/// Left column: exchanges (45%) + credentials (35%) + stats (20%) +pub fn left_panels(area: Rect) -> (Rect, Rect, Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(45), + Constraint::Percentage(35), + Constraint::Percentage(20), + ]) + .split(area); + (rows[0], rows[1], rows[2]) +} + +/// Right column: funding (65%) + account (35%) +pub fn right_panels(area: Rect) -> (Rect, Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) + .split(area); + (rows[0], rows[1]) +} +``` + +**Step 4: Create `widgets/mod.rs`** + +```rust +pub mod header; +pub mod footer; +pub mod exchanges; +pub mod credentials; +pub mod funding; +pub mod account; +pub mod stats; +``` + +**Step 5: Stub `lib.rs` with module declarations** + +Replace the entire contents of `lib.rs` with: + +```rust +mod layout; +mod state; +mod style; +mod widgets; + +pub use state::DashState; + +pub async fn run() -> anyhow::Result<()> { + todo!("will be implemented in task 6") +} +``` + +**Step 6: Verify it compiles** + +Run: `cargo check -p perps-tui` +Expected: compiles (with unused warnings, that's fine) + +**Step 7: Commit** + +```bash +git add crates/perps-tui/src/ +git commit -m "refactor(tui): scaffold modular structure with style, state, layout" +``` + +--- + +### Task 2: Header widget + +**Files:** +- Create: `crates/perps-tui/src/widgets/header.rs` + +**Step 1: Implement the header** + +The header is a single line showing: title left, status chips center, time right. + +```rust +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; +use crate::{state::DashState, style}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let healthy = state.health.iter().filter(|h| h.ok).count(); + let total = state.health.len(); + let opps = state.spreads.iter().filter(|s| s.apr_delta > 0.0).count(); + let now = chrono::Local::now().format("%H:%M %p").to_string(); + + let health_style = if healthy == total && total > 0 { + style::GOOD + } else if total == 0 { + style::DIM + } else { + style::WARN + }; + + let line = Line::from(vec![ + Span::styled(" RAINTREE PERPS", style::TITLE), + Span::raw(" "), + Span::styled(format!("{healthy}/{total} up"), health_style), + Span::styled(" · ", style::DIM), + Span::styled( + format!("{opps} opps"), + if opps > 0 { style::GOOD } else { style::DIM }, + ), + // Right-align the time by padding — we'll use a simpler approach: + // just append with spaces. The Paragraph won't right-align spans natively, + // so we pad to fill the line width. + Span::raw(" ".repeat(area.width.saturating_sub(40) as usize)), + Span::styled(now, style::DIM), + Span::raw(" "), + ]); + + f.render_widget(Paragraph::new(line), area); +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 3: Commit** + +```bash +git add crates/perps-tui/src/widgets/header.rs +git commit -m "feat(tui): header widget with status chips and clock" +``` + +--- + +### Task 3: Footer widget + +**Files:** +- Create: `crates/perps-tui/src/widgets/footer.rs` + +**Step 1: Implement the footer** + +Single line: keybindings left, cycle counter right. + +```rust +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; +use crate::{state::DashState, style}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let mut spans = vec![]; + + if state.search_query.is_empty() { + spans.extend([ + Span::styled(" /", style::CYAN), + Span::styled(" search ", style::DIM), + Span::styled("q", style::CYAN), + Span::styled(" quit", style::DIM), + ]); + } else { + spans.extend([ + Span::styled(" search: ", style::CYAN), + Span::styled(format!("{}_", state.search_query), style::VALUE_BOLD), + Span::raw(" "), + Span::styled("esc", style::CYAN), + Span::styled(" clear", style::DIM), + ]); + } + + // Right-pad then cycle counter + let used: usize = spans.iter().map(|s| s.content.len()).sum(); + let remaining = (area.width as usize).saturating_sub(used + 12); + spans.push(Span::raw(" ".repeat(remaining))); + spans.push(Span::styled(format!("cycle {}", state.cycle), style::DIM)); + spans.push(Span::raw(" ")); + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 3: Commit** + +```bash +git add crates/perps-tui/src/widgets/footer.rs +git commit -m "feat(tui): footer widget with search and cycle counter" +``` + +--- + +### Task 4: Exchanges table widget + +**Files:** +- Create: `crates/perps-tui/src/widgets/exchanges.rs` + +**Step 1: Implement exchanges table** + +Shows: Exchange, Health (dot + OK/DOWN), Tier, Ping, CB state. + +```rust +use ratatui::{ + layout::{Constraint, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; +use crate::{state::DashState, style}; + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let block = Block::default() + .title(" EXCHANGES ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + if state.health.is_empty() { + let spin = SPINNER_FRAMES[(state.cycle as usize) % SPINNER_FRAMES.len()]; + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" {spin} Loading connectors..."), + style::WARN, + )), + ]) + .block(block); + f.render_widget(p, area); + return; + } + + // Build scorecard + circuit breaker lookups + let scorecard_map: std::collections::HashMap<&str, &perps_app::ConnectorScorecard> = state + .scorecard + .iter() + .map(|s| (s.connector_id.as_str(), s)) + .collect(); + + let cb_map: std::collections::HashMap = state + .circuit_breakers + .iter() + .filter_map(|v| { + let id = v.get("connector_id")?.as_str()?.to_string(); + let st = v.get("state")?.as_str()?.to_string(); + Some((id, st)) + }) + .collect(); + + let rows = state.health.iter().map(|h| { + let name = display_name(&h.connector_id); + let (status_text, status_style) = if h.ok { + ("OK", style::GOOD_BOLD) + } else { + ("DOWN", style::BAD_BOLD) + }; + + let (tier_text, tier_sty) = scorecard_map + .get(h.connector_id.as_str()) + .map(|sc| style::tier_style(&sc.tier)) + .unwrap_or(("-", style::DIM)); + + let ping = format!("{}ms", h.latency_ms); + let ping_sty = style::latency_style(h.latency_ms); + + let cb_state = cb_map + .get(&h.connector_id) + .map(|s| s.as_str()) + .unwrap_or("?"); + let (cb_text, cb_sty) = match cb_state { + "closed" => ("✓", style::GOOD), + "open" => ("✗", style::BAD), + _ => ("?", style::DIM), + }; + + Row::new(vec![ + Cell::from(name).style(style::VALUE), + Cell::from(status_text).style(status_style), + Cell::from(tier_text).style(tier_sty), + Cell::from(ping).style(ping_sty), + Cell::from(cb_text).style(cb_sty), + ]) + }); + + let table = Table::new( + rows, + [ + Constraint::Min(12), // Exchange + Constraint::Length(5), // Health + Constraint::Length(5), // Tier + Constraint::Length(7), // Ping + Constraint::Length(3), // CB + ], + ) + .header( + Row::new(vec!["Exchange", "●", "Tier", "Ping", "CB"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block(block); + + f.render_widget(table, area); +} + +fn display_name(connector_id: &str) -> String { + let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); + let mut chars = raw.chars(); + match chars.next() { + Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()), + None => raw.to_string(), + } +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 3: Commit** + +```bash +git add crates/perps-tui/src/widgets/exchanges.rs +git commit -m "feat(tui): exchanges table with health, tier, ping, circuit breaker" +``` + +--- + +### Task 5: Credentials table widget + +**Files:** +- Create: `crates/perps-tui/src/widgets/credentials.rs` + +**Step 1: Implement credentials table** + +Shows: Exchange, Keys (set/none), Trade (yes + tier / no), Rate limit budget. + +The credential detection logic checks which env vars are set at runtime. We know from `crates/perps-app/src/lib.rs:220-307` which env vars each connector uses. Rather than duplicating that, we use the auth_status from `ops_auth_check()` + scorecard `execution_enabled`. + +```rust +use ratatui::{ + layout::{Constraint, Rect}, + widgets::{Block, Borders, Cell, Row, Table}, + Frame, +}; +use crate::{state::DashState, style}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let block = Block::default() + .title(" CREDENTIALS & TRADING ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + let scorecard_map: std::collections::HashMap<&str, &perps_app::ConnectorScorecard> = state + .scorecard + .iter() + .map(|s| (s.connector_id.as_str(), s)) + .collect(); + + let auth_map: std::collections::HashMap = state + .auth_status + .iter() + .filter_map(|v| { + let id = v.get("connector_id")?.as_str()?.to_string(); + let st = v.get("auth_status")?.as_str()?.to_string(); + Some((id, st)) + }) + .collect(); + + let rate_map: std::collections::HashMap = state + .rate_limits + .iter() + .filter_map(|v| { + let id = v.get("connector_id")?.as_str()?.to_string(); + let status = v.get("rate_limit_budget") + .and_then(|b| b.get("status")) + .and_then(|s| s.as_str()) + .unwrap_or("unknown") + .to_string(); + Some((id, status)) + }) + .collect(); + + let rows = state.health.iter().map(|h| { + let name = display_name(&h.connector_id); + + let auth = auth_map + .get(&h.connector_id) + .map(|s| s.as_str()) + .unwrap_or("unknown"); + let (keys_text, keys_sty) = match auth { + "valid" => ("✓ set", style::GOOD), + "degraded" => ("~ deg", style::WARN), + _ => ("✗ none", style::DIM), + }; + + let sc = scorecard_map.get(h.connector_id.as_str()); + let (trade_text, trade_sty) = if let Some(sc) = sc { + if sc.execution_enabled { + let (t, s) = style::tier_style(&sc.tier); + (format!("✓ {t}"), s) + } else { + ("✗".to_string(), style::DIM) + } + } else { + ("?".to_string(), style::DIM) + }; + + let rate_status = rate_map + .get(&h.connector_id) + .map(|s| s.as_str()) + .unwrap_or("unknown"); + let (rate_text, rate_sty) = match rate_status { + "unknown" => ("—".to_string(), style::DIM), + other => (other.to_string(), style::VALUE), + }; + + Row::new(vec![ + Cell::from(name).style(style::VALUE), + Cell::from(keys_text).style(keys_sty), + Cell::from(trade_text).style(trade_sty), + Cell::from(rate_text).style(rate_sty), + ]) + }); + + let table = Table::new( + rows, + [ + Constraint::Min(12), // Exchange + Constraint::Length(7), // Keys + Constraint::Length(6), // Trade + Constraint::Length(8), // Rate + ], + ) + .header( + Row::new(vec!["Exchange", "Keys", "Trade", "Rate"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block(block); + + f.render_widget(table, area); +} + +fn display_name(connector_id: &str) -> String { + let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); + let mut chars = raw.chars(); + match chars.next() { + Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()), + None => raw.to_string(), + } +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 3: Commit** + +```bash +git add crates/perps-tui/src/widgets/credentials.rs +git commit -m "feat(tui): credentials table with auth, trading, rate limit status" +``` + +--- + +### Task 6: Stats panel widget + +**Files:** +- Create: `crates/perps-tui/src/widgets/stats.rs` + +**Step 1: Implement stats panel** + +3 lines of condensed summary text (no border, just a titled block). + +```rust +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use crate::{state::DashState, style}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let opps = state.spreads.iter().filter(|s| s.apr_delta > 0.0).count(); + let unique_venues: std::collections::HashSet<&str> = state + .funding_raw + .iter() + .map(|r| r.venue_id.as_str()) + .collect(); + let unique_assets: std::collections::HashSet = state + .funding_raw + .iter() + .map(|r| r.market_uid.base.to_uppercase()) + .collect(); + + let best_apr = state + .spreads + .first() + .map(|s| s.apr_delta * 100.0) + .unwrap_or(0.0); + + let median_apr = if !state.spreads.is_empty() { + let mut aprs: Vec = state.spreads.iter().map(|s| s.apr_delta * 100.0).collect(); + aprs.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + aprs[aprs.len() / 2] + } else { + 0.0 + }; + + let tradeable = state + .scorecard + .iter() + .filter(|s| s.execution_enabled) + .count(); + let avg_ping = if !state.health.is_empty() { + state.health.iter().map(|h| h.latency_ms).sum::() / state.health.len() as u64 + } else { + 0 + }; + + let lines = vec![ + Line::from(vec![ + Span::styled( + format!(" {} opps", opps), + if opps > 0 { style::GOOD } else { style::DIM }, + ), + Span::styled( + format!(" · {} venues · {} assets", unique_venues.len(), unique_assets.len()), + style::DIM, + ), + ]), + Line::from(vec![ + Span::styled(format!(" best {best_apr:+.1}%"), style::apr_style(best_apr)), + Span::styled(format!(" · median {median_apr:+.1}%"), style::DIM), + ]), + Line::from(vec![ + Span::styled(format!(" {tradeable} tradeable"), style::GOOD), + Span::styled(format!(" · avg ping {avg_ping}ms"), style::DIM), + ]), + ]; + + let block = Block::default() + .title(" STATS ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + f.render_widget(Paragraph::new(lines).block(block), area); +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 3: Commit** + +```bash +git add crates/perps-tui/src/widgets/stats.rs +git commit -m "feat(tui): stats summary panel" +``` + +--- + +### Task 7: Funding opportunities table widget + +**Files:** +- Create: `crates/perps-tui/src/widgets/funding.rs` + +**Step 1: Implement funding table** + +This is the largest widget — shows Market, Short, Long, APR, Signal with search filtering. Port the existing logic from old `draw_funding_table` but simplified (drop hourly/8h/edge columns, keep it clean). + +```rust +use ratatui::{ + layout::{Constraint, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; +use crate::{state::DashState, style}; + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let search_hint = if state.search_query.is_empty() { + " / search".to_string() + } else { + format!(" \"{}\"", state.search_query) + }; + + let block = Block::default() + .title(" FUNDING OPPORTUNITIES ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + // Loading state + if state.spreads.is_empty() && state.last_funding_refresh.is_none() { + let spin = SPINNER_FRAMES[(state.cycle as usize) % SPINNER_FRAMES.len()]; + let pct = if state.funding_assets_total > 0 { + state.funding_assets_scanned * 100 / state.funding_assets_total + } else { + 0 + }; + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!( + " {spin} Scanning {}/{} assets... {pct}%", + state.funding_assets_scanned, state.funding_assets_total + ), + style::WARN, + )), + ]) + .block(block); + f.render_widget(p, area); + return; + } + + let screen_rows = area.height.saturating_sub(4) as usize; + let search_upper = state.search_query.to_uppercase(); + + let filtered: Vec<_> = state + .spreads + .iter() + .filter(|s| { + if search_upper.is_empty() { + return true; + } + let market = display_market(&s.market_key).to_uppercase(); + let short = s.short_venue.to_uppercase(); + let long = s.long_venue.to_uppercase(); + market.contains(&search_upper) + || short.contains(&search_upper) + || long.contains(&search_upper) + }) + .take(screen_rows) + .collect(); + + let rows = filtered.iter().map(|s| { + let market = display_market(&s.market_key); + let short = display_name(&s.short_venue); + let long = display_name(&s.long_venue); + let apr_pct = s.apr_delta * 100.0; + let apr_text = format_rate(apr_pct); + + let signal = if s.apr_delta > 0.0 { "BUY" } else { "—" }; + let sig_sty = if s.apr_delta > 0.0 { style::GOOD } else { style::DIM }; + + Row::new(vec![ + Cell::from(market).style(style::VALUE_BOLD), + Cell::from(truncate(& short, 10)).style(style::VALUE), + Cell::from(truncate(&long, 10)).style(style::VALUE), + Cell::from(apr_text).style(style::apr_style(apr_pct)), + Cell::from(signal).style(sig_sty), + ]) + }); + + let title = if search_upper.is_empty() { + format!( + " FUNDING OPPORTUNITIES ({}) ", + state.spreads.len() + ) + } else { + format!( + " FUNDING — \"{}\" ({} matches) ", + state.search_query, + filtered.len() + ) + }; + + let table = Table::new( + rows, + [ + Constraint::Length(10), // Market + Constraint::Length(10), // Short + Constraint::Length(10), // Long + Constraint::Length(9), // APR + Constraint::Min(4), // Signal + ], + ) + .header( + Row::new(vec!["Market", "Short", "Long", "APR", "Sig"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block( + Block::default() + .title(title) + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER), + ); + + f.render_widget(table, area); +} + +fn display_name(connector_id: &str) -> String { + let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); + let mut chars = raw.chars(); + match chars.next() { + Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()), + None => raw.to_string(), + } +} + +fn display_market(market_key: &str) -> String { + let parts: Vec<&str> = market_key.split(':').collect(); + if parts.len() >= 3 { + format!("{}/{}", parts[1].to_uppercase(), parts[2].to_uppercase()) + } else { + market_key.to_uppercase() + } +} + +fn format_rate(pct: f64) -> String { + let abs = pct.abs(); + let sign = if pct >= 0.0 { "+" } else { "-" }; + if abs >= 1000.0 { + format!("{sign}{:.1}K%", abs / 1000.0) + } else if abs >= 100.0 { + format!("{sign}{:.0}%", abs) + } else if abs >= 1.0 { + format!("{sign}{:.2}%", abs) + } else { + format!("{sign}{:.4}%", abs) + } +} + +fn truncate(input: &str, width: usize) -> String { + if input.chars().count() <= width { + input.to_string() + } else { + let clipped: String = input.chars().take(width.saturating_sub(1)).collect(); + format!("{clipped}…") + } +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 3: Commit** + +```bash +git add crates/perps-tui/src/widgets/funding.rs +git commit -m "feat(tui): funding opportunities table with search filtering" +``` + +--- + +### Task 8: Account overview widget + +**Files:** +- Create: `crates/perps-tui/src/widgets/account.rs` + +**Step 1: Implement account table** + +Shows per-venue equity, unrealized P&L, margin (leverage ratio). If no account data yet, shows a placeholder. + +```rust +use ratatui::{ + layout::{Constraint, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; +use crate::{state::DashState, style}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let block = Block::default() + .title(" ACCOUNT ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + let account = match &state.account { + Some(a) => a, + None => { + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " No account data (set exchange credentials)", + style::DIM, + )), + ]) + .block(block); + f.render_widget(p, area); + return; + } + }; + + if account.balances.is_empty() { + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled(" No balances found", style::DIM)), + ]) + .block(block); + f.render_widget(p, area); + return; + } + + // Group balances by venue + let mut venue_equity: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for b in &account.balances { + *venue_equity.entry(b.venue_id.clone()).or_default() += b.total; + } + + // Group unrealized P&L by venue + let mut venue_pnl: std::collections::HashMap = + std::collections::HashMap::new(); + for p in &account.positions { + *venue_pnl.entry(p.venue_id.clone()).or_default() += p.unrealized_pnl; + } + + let mut data_rows: Vec = venue_equity + .iter() + .map(|(venue, &equity)| { + let pnl = venue_pnl.get(venue).copied().unwrap_or(0.0); + let margin_pct = if equity > 0.0 { + let venue_exposure: f64 = account + .positions + .iter() + .filter(|p| &p.venue_id == venue) + .map(|p| (p.size * p.mark_price).abs()) + .sum(); + venue_exposure / equity * 100.0 + } else { + 0.0 + }; + + let name = display_name(venue); + Row::new(vec![ + Cell::from(name).style(style::VALUE), + Cell::from(format!("${equity:.0}")).style(style::VALUE), + Cell::from(format!("{pnl:+.0}")).style(style::pnl_style(pnl)), + Cell::from(format!("{margin_pct:.0}%")).style(style::margin_style(margin_pct)), + ]) + }) + .collect(); + + // Total row + let total_pnl: f64 = account.positions.iter().map(|p| p.unrealized_pnl).sum(); + let total_margin = if account.total_equity > 0.0 { + account.total_exposure_gross / account.total_equity * 100.0 + } else { + 0.0 + }; + data_rows.push( + Row::new(vec![ + Cell::from("Total").style(style::VALUE_BOLD), + Cell::from(format!("${:.0}", account.total_equity)).style(style::VALUE_BOLD), + Cell::from(format!("{total_pnl:+.0}")).style(style::pnl_style(total_pnl)), + Cell::from(format!("{total_margin:.0}%")).style(style::margin_style(total_margin)), + ]), + ); + + let table = Table::new( + data_rows, + [ + Constraint::Min(12), // Venue + Constraint::Length(10), // Equity + Constraint::Length(9), // P&L + Constraint::Length(7), // Margin + ], + ) + .header( + Row::new(vec!["Venue", "Equity", "Unrl P&L", "Margin"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block(block); + + f.render_widget(table, area); +} + +fn display_name(connector_id: &str) -> String { + let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); + let mut chars = raw.chars(); + match chars.next() { + Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()), + None => raw.to_string(), + } +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 3: Commit** + +```bash +git add crates/perps-tui/src/widgets/account.rs +git commit -m "feat(tui): account overview widget with per-venue equity and P&L" +``` + +--- + +### Task 9: Wire up pollers and main event loop + +**Files:** +- Modify: `crates/perps-tui/src/lib.rs` (replace the `todo!()` with full implementation) + +**Step 1: Implement the full `run()` function** + +This is the core — bootstrap, spawn pollers, run the render loop. Port the bootstrap animation from the old code. Add new pollers for account state, auth check, circuit breakers, rate limits. + +Key pollers: +- **Health + scorecard + circuit breakers + rate limits** — every 5s (single poller, 4 API calls) +- **Funding spreads** — every 15s (existing batched scanner) +- **Account state** — every 10s +- **Auth check** — every 30s + +The `run()` function in `lib.rs` should: + +1. `enable_raw_mode()` + `EnterAlternateScreen` +2. Show loading animation while `PerpsApp::bootstrap()` runs +3. Create all watch channels +4. Spawn background pollers +5. Main loop: poll events (200ms), update `DashState` from channels, call layout + widget draw functions +6. On `q` (when not searching): cleanup and exit + +```rust +// lib.rs — replace the todo!() stub with the full implementation. +// Keep the module declarations from Task 1. +// The full code follows the same pattern as the old lib.rs but uses +// the new modular widgets and layout. + +mod layout; +mod state; +mod style; +mod widgets; + +use std::{ + io, + time::{Duration, Instant}, +}; + +use crossterm::{ + event::{self, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use perps_app::PerpsApp; +use ratatui::{ + backend::CrosstermBackend, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Terminal, +}; +use tokio::sync::watch; + +use crate::state::DashState; + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const FUNDING_BATCH_SIZE: usize = 10; +const MAX_SPREAD_ASSETS: usize = 25; + +const LOGO: &[&str] = &[ + r" ██████╗ █████╗ ██╗███╗ ██╗████████╗██████╗ ███████╗███████╗", + r" ██╔══██╗██╔══██╗██║████╗ ██║╚══██╔══╝██╔══██╗██╔════╝██╔════╝", + r" ██████╔╝███████║██║██╔██╗ ██║ ██║ ██████╔╝█████╗ █████╗ ", + r" ██╔══██╗██╔══██║██║██║╚██╗██║ ██║ ██╔══██╗██╔══╝ ██╔══╝ ", + r" ██║ ██║██║ ██║██║██║ ╚████║ ██║ ██║ ██║███████╗███████╗", + r" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝", +]; + +pub async fn run() -> anyhow::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // --- Bootstrap with animated loading --- + let boot_start = Instant::now(); + let mut frame_idx: usize = 0; + draw_loading(&mut terminal, "Bootstrapping connectors...", frame_idx)?; + + let (boot_tx, mut boot_rx) = tokio::sync::oneshot::channel::>(); + tokio::spawn(async move { + let _ = boot_tx.send(PerpsApp::bootstrap().await); + }); + + let app = loop { + frame_idx = frame_idx.wrapping_add(1); + let elapsed = boot_start.elapsed().as_secs(); + let msg = if elapsed < 3 { + "Bootstrapping connectors..." + } else if elapsed < 8 { + "Connecting to exchanges..." + } else { + "Almost ready..." + }; + draw_loading(&mut terminal, msg, frame_idx)?; + + if event::poll(Duration::from_millis(80))? { + if let Event::Key(key) = event::read()? { + if key.code == KeyCode::Char('q') { + cleanup(&mut terminal)?; + return Ok(()); + } + } + } + + match boot_rx.try_recv() { + Ok(Ok(app)) => break app, + Ok(Err(e)) => { + cleanup(&mut terminal)?; + return Err(e); + } + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => continue, + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { + cleanup(&mut terminal)?; + anyhow::bail!("bootstrap task panicked"); + } + } + }; + + // --- Watch channels --- + let (health_tx, health_rx) = watch::channel(vec![]); + let (scorecard_tx, scorecard_rx) = watch::channel(vec![]); + let (spread_tx, spread_rx) = watch::channel(vec![]); + let (funding_raw_tx, funding_raw_rx) = watch::channel(vec![]); + let (health_ts_tx, health_ts_rx) = watch::channel::>(None); + let (funding_ts_tx, funding_ts_rx) = watch::channel::>(None); + let (funding_progress_tx, funding_progress_rx) = watch::channel((0usize, 0usize)); + let (account_tx, account_rx) = watch::channel::>(None); + let (account_ts_tx, account_ts_rx) = watch::channel::>(None); + let (auth_tx, auth_rx) = watch::channel::>(vec![]); + let (cb_tx, cb_rx) = watch::channel::>(vec![]); + let (rate_tx, rate_rx) = watch::channel::>(vec![]); + + // --- Health + scorecard + CB + rate limits poller (every 5s) --- + let app_health = app.clone(); + tokio::spawn(async move { + loop { + if let Ok(rows) = app_health.connector_health().await { + let _ = health_tx.send(rows); + let _ = health_ts_tx.send(Some(Instant::now())); + } + if let Ok(rows) = app_health.connector_scorecard().await { + let _ = scorecard_tx.send(rows); + } + if let Ok(cbs) = app_health.ops_circuit_breakers().await { + let _ = cb_tx.send(cbs); + } + if let Ok(rates) = app_health.ops_rate_limits().await { + let _ = rate_tx.send(rates); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + }); + + // --- Auth check poller (every 30s) --- + let app_auth = app.clone(); + tokio::spawn(async move { + loop { + if let Ok(checks) = app_auth.ops_auth_check().await { + let _ = auth_tx.send(checks); + } + tokio::time::sleep(Duration::from_secs(30)).await; + } + }); + + // --- Account state poller (every 10s) --- + let app_account = app.clone(); + tokio::spawn(async move { + loop { + if let Ok(state) = app_account.account_state(None).await { + let _ = account_tx.send(Some(state)); + let _ = account_ts_tx.send(Some(Instant::now())); + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + }); + + // --- Funding poller (every 15s) --- + let app_funding = app.clone(); + tokio::spawn(async move { + loop { + let candidates = app_funding.universe_list(); + let mut venue_count: std::collections::HashMap = + std::collections::HashMap::new(); + for c in &candidates { + if c.venue_id.starts_with("ccxt:") { + continue; + } + *venue_count + .entry(c.market_uid.base.to_uppercase()) + .or_default() += 1; + } + let mut ranked: Vec<(String, usize)> = venue_count.into_iter().collect(); + ranked.sort_by(|a, b| b.1.cmp(&a.1)); + let assets: Vec = ranked + .into_iter() + .take(MAX_SPREAD_ASSETS) + .map(|(a, _)| a) + .collect(); + let total = assets.len(); + + let mut all_rows = Vec::new(); + for (batch_idx, batch) in assets.chunks(FUNDING_BATCH_SIZE).enumerate() { + let scanned_so_far = batch_idx * FUNDING_BATCH_SIZE; + let _ = funding_progress_tx.send((scanned_so_far, total)); + + let mut handles = Vec::new(); + for asset in batch { + let app_clone = app_funding.clone(); + let asset_owned = asset.clone(); + handles.push(tokio::spawn(async move { + app_clone.funding_scan(Some(&asset_owned)).await + })); + } + for handle in handles { + if let Ok(Ok(rows)) = handle.await { + all_rows.extend( + rows.into_iter() + .filter(|r| !r.venue_id.starts_with("ccxt:")), + ); + } + } + } + let _ = funding_progress_tx.send((total, total)); + let spreads = perps_funding::find_best_spreads(&all_rows); + let _ = spread_tx.send(spreads); + let _ = funding_raw_tx.send(all_rows); + let _ = funding_ts_tx.send(Some(Instant::now())); + tokio::time::sleep(Duration::from_secs(15)).await; + } + }); + + // --- Main event loop --- + let mut should_quit = false; + let mut cycle: u64 = 0; + let mut search_query = String::new(); + + while !should_quit { + cycle = cycle.saturating_add(1); + let (scanned, total) = *funding_progress_rx.borrow(); + + let dash_state = DashState { + health: health_rx.borrow().clone(), + scorecard: scorecard_rx.borrow().clone(), + spreads: spread_rx.borrow().clone(), + funding_raw: funding_raw_rx.borrow().clone(), + account: account_rx.borrow().clone(), + auth_status: auth_rx.borrow().clone(), + circuit_breakers: cb_rx.borrow().clone(), + rate_limits: rate_rx.borrow().clone(), + last_health_refresh: *health_ts_rx.borrow(), + last_funding_refresh: *funding_ts_rx.borrow(), + last_account_refresh: *account_ts_rx.borrow(), + funding_assets_scanned: scanned, + funding_assets_total: total, + search_query: search_query.clone(), + cycle, + }; + + draw_dashboard(&mut terminal, &dash_state)?; + + if event::poll(Duration::from_millis(200))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('q') if search_query.is_empty() => should_quit = true, + KeyCode::Esc => search_query.clear(), + KeyCode::Backspace => { search_query.pop(); } + KeyCode::Char(c) => search_query.push(c), + _ => {} + } + } + } + } + + cleanup(&mut terminal)?; + Ok(()) +} + +fn cleanup(terminal: &mut Terminal>) -> io::Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +fn draw_loading( + terminal: &mut Terminal>, + message: &str, + frame: usize, +) -> io::Result<()> { + let msg = message.to_string(); + let spin = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]; + terminal.draw(|f| { + let area = f.area(); + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Percentage(25), + ratatui::layout::Constraint::Length(12), + ratatui::layout::Constraint::Percentage(25), + ]) + .split(area); + + let center = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + ratatui::layout::Constraint::Percentage(15), + ratatui::layout::Constraint::Percentage(70), + ratatui::layout::Constraint::Percentage(15), + ]) + .split(chunks[1]); + + let mut lines: Vec = vec![Line::from("")]; + for logo_line in LOGO { + lines.push(Line::from(vec![Span::styled( + *logo_line, + Style::default().fg(Color::Cyan), + )])); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + format!(" {spin} "), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled(msg, Style::default().fg(Color::Yellow)), + ])); + + let loading = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ); + f.render_widget(loading, center[1]); + })?; + Ok(()) +} + +fn draw_dashboard( + terminal: &mut Terminal>, + state: &DashState, +) -> io::Result<()> { + terminal.draw(|f| { + let (header_area, body_area, footer_area) = layout::main_layout(f.area()); + let (left_col, right_col) = layout::body_columns(body_area); + let (exchanges_area, creds_area, stats_area) = layout::left_panels(left_col); + let (funding_area, account_area) = layout::right_panels(right_col); + + widgets::header::draw(f, header_area, state); + widgets::exchanges::draw(f, exchanges_area, state); + widgets::credentials::draw(f, creds_area, state); + widgets::stats::draw(f, stats_area, state); + widgets::funding::draw(f, funding_area, state); + widgets::account::draw(f, account_area, state); + widgets::footer::draw(f, footer_area, state); + })?; + Ok(()) +} +``` + +**Step 2: Update `Cargo.toml` to add `serde_json` dependency if needed** + +Check if `serde_json` is already available through workspace deps. If not, add: + +```toml +serde_json.workspace = true +``` + +**Step 3: Verify it compiles** + +Run: `cargo check -p perps-tui` + +Fix any compilation errors (likely minor import issues, lifetime annotations, or type mismatches). + +**Step 4: Run the full workspace check** + +Run: `cargo check --workspace` + +**Step 5: Commit** + +```bash +git add crates/perps-tui/ +git commit -m "feat(tui): complete dashboard rewrite with 6-panel layout" +``` + +--- + +### Task 10: Integration test — build and run + +**Step 1: Run clippy** + +Run: `cargo clippy -p perps-tui -- -W warnings` + +Fix any warnings. + +**Step 2: Run fmt check** + +Run: `cargo fmt --all -- --check` + +Fix any formatting issues with `cargo fmt --all`. + +**Step 3: Run unit tests** + +Run: `cargo test --lib -p perps-tui` + +**Step 4: Build release** + +Run: `cargo build -p perps-tui --release` + +**Step 5: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix(tui): clippy and fmt fixes" +``` + +--- + +### Task 11: Extract shared `display_name` helper to avoid duplication + +The `display_name`, `display_market`, `truncate`, and `format_rate` functions are duplicated across widget modules. Extract them into a shared helpers module. + +**Files:** +- Create: `crates/perps-tui/src/helpers.rs` +- Modify: `crates/perps-tui/src/lib.rs` (add `mod helpers;`) +- Modify: All widget files that use these functions (replace local definitions with `use crate::helpers::*`) + +**Step 1: Create `helpers.rs`** + +```rust +pub fn display_name(connector_id: &str) -> String { + let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); + let mut chars = raw.chars(); + match chars.next() { + Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()), + None => raw.to_string(), + } +} + +pub fn display_market(market_key: &str) -> String { + let parts: Vec<&str> = market_key.split(':').collect(); + if parts.len() >= 3 { + format!("{}/{}", parts[1].to_uppercase(), parts[2].to_uppercase()) + } else { + market_key.to_uppercase() + } +} + +pub fn truncate(input: &str, width: usize) -> String { + if input.chars().count() <= width { + input.to_string() + } else { + let clipped: String = input.chars().take(width.saturating_sub(1)).collect(); + format!("{clipped}…") + } +} + +pub fn format_rate(pct: f64) -> String { + let abs = pct.abs(); + let sign = if pct >= 0.0 { "+" } else { "-" }; + if abs >= 1_000_000.0 { + format!("{sign}{:.1}M%", abs / 1_000_000.0) + } else if abs >= 1_000.0 { + format!("{sign}{:.1}K%", abs / 1_000.0) + } else if abs >= 100.0 { + format!("{sign}{:.0}%", abs) + } else if abs >= 1.0 { + format!("{sign}{:.2}%", abs) + } else { + format!("{sign}{:.4}%", abs) + } +} +``` + +**Step 2: Update widget files to use `crate::helpers::*` instead of local functions** + +Remove `display_name`, `display_market`, `truncate`, `format_rate` from each widget file and replace with `use crate::helpers::{display_name, display_market, truncate, format_rate};` as needed. + +**Step 3: Verify it compiles** + +Run: `cargo check -p perps-tui` + +**Step 4: Commit** + +```bash +git add crates/perps-tui/src/ +git commit -m "refactor(tui): extract shared display helpers to helpers.rs" +``` From d3bdf971413cd8f32b3a8c34f385e800a00f5440 Mon Sep 17 00:00:00 2001 From: Zachary Roth Date: Tue, 3 Mar 2026 13:16:37 -0800 Subject: [PATCH 3/6] feat(tui): complete dashboard rewrite with 6-panel modular layout Replaces monolithic lib.rs with modular structure: - helpers.rs: shared display formatters - state.rs: centralized DashState - layout.rs: grid proportions - style.rs: semantic color constants - widgets/: header, footer, exchanges, credentials, funding, account, stats New panels: credentials & trading readiness, account overview. New pollers: auth check (30s), account state (10s), circuit breakers, rate limits. Co-Authored-By: Claude Opus 4.6 --- crates/perps-tui/Cargo.toml | 1 + crates/perps-tui/src/helpers.rs | 42 + crates/perps-tui/src/layout.rs | 41 + crates/perps-tui/src/lib.rs | 1092 ++----------------- crates/perps-tui/src/state.rs | 25 + crates/perps-tui/src/style.rs | 67 ++ crates/perps-tui/src/widgets/account.rs | 109 ++ crates/perps-tui/src/widgets/credentials.rs | 105 ++ crates/perps-tui/src/widgets/exchanges.rs | 104 ++ crates/perps-tui/src/widgets/footer.rs | 36 + crates/perps-tui/src/widgets/funding.rs | 124 +++ crates/perps-tui/src/widgets/header.rs | 38 + crates/perps-tui/src/widgets/mod.rs | 7 + crates/perps-tui/src/widgets/stats.rs | 79 ++ 14 files changed, 883 insertions(+), 987 deletions(-) create mode 100644 crates/perps-tui/src/helpers.rs create mode 100644 crates/perps-tui/src/layout.rs create mode 100644 crates/perps-tui/src/state.rs create mode 100644 crates/perps-tui/src/style.rs create mode 100644 crates/perps-tui/src/widgets/account.rs create mode 100644 crates/perps-tui/src/widgets/credentials.rs create mode 100644 crates/perps-tui/src/widgets/exchanges.rs create mode 100644 crates/perps-tui/src/widgets/footer.rs create mode 100644 crates/perps-tui/src/widgets/funding.rs create mode 100644 crates/perps-tui/src/widgets/header.rs create mode 100644 crates/perps-tui/src/widgets/mod.rs create mode 100644 crates/perps-tui/src/widgets/stats.rs diff --git a/crates/perps-tui/Cargo.toml b/crates/perps-tui/Cargo.toml index 68911db..87636ca 100644 --- a/crates/perps-tui/Cargo.toml +++ b/crates/perps-tui/Cargo.toml @@ -14,4 +14,5 @@ perps-connector-api = { path = "../perps-connector-api" } perps-domain = { path = "../perps-domain" } perps-funding = { path = "../perps-funding" } ratatui.workspace = true +serde_json.workspace = true tokio.workspace = true diff --git a/crates/perps-tui/src/helpers.rs b/crates/perps-tui/src/helpers.rs new file mode 100644 index 0000000..1c29074 --- /dev/null +++ b/crates/perps-tui/src/helpers.rs @@ -0,0 +1,42 @@ +pub fn display_name(connector_id: &str) -> String { + let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); + let mut chars = raw.chars(); + match chars.next() { + Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()), + None => raw.to_string(), + } +} + +pub fn display_market(market_key: &str) -> String { + let parts: Vec<&str> = market_key.split(':').collect(); + if parts.len() >= 3 { + format!("{}/{}", parts[1].to_uppercase(), parts[2].to_uppercase()) + } else { + market_key.to_uppercase() + } +} + +pub fn truncate(input: &str, width: usize) -> String { + if input.chars().count() <= width { + input.to_string() + } else { + let clipped: String = input.chars().take(width.saturating_sub(1)).collect(); + format!("{clipped}\u{2026}") + } +} + +pub fn format_rate(pct: f64) -> String { + let abs = pct.abs(); + let sign = if pct >= 0.0 { "+" } else { "-" }; + if abs >= 1_000_000.0 { + format!("{sign}{:.1}M%", abs / 1_000_000.0) + } else if abs >= 1_000.0 { + format!("{sign}{:.1}K%", abs / 1_000.0) + } else if abs >= 100.0 { + format!("{sign}{:.0}%", abs) + } else if abs >= 1.0 { + format!("{sign}{:.2}%", abs) + } else { + format!("{sign}{:.4}%", abs) + } +} diff --git a/crates/perps-tui/src/layout.rs b/crates/perps-tui/src/layout.rs new file mode 100644 index 0000000..c32e409 --- /dev/null +++ b/crates/perps-tui/src/layout.rs @@ -0,0 +1,41 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +pub fn main_layout(area: Rect) -> (Rect, Rect, Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(10), + Constraint::Length(1), + ]) + .split(area); + (chunks[0], chunks[1], chunks[2]) +} + +pub fn body_columns(area: Rect) -> (Rect, Rect) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(area); + (cols[0], cols[1]) +} + +pub fn left_panels(area: Rect) -> (Rect, Rect, Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(45), + Constraint::Percentage(35), + Constraint::Percentage(20), + ]) + .split(area); + (rows[0], rows[1], rows[2]) +} + +pub fn right_panels(area: Rect) -> (Rect, Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) + .split(area); + (rows[0], rows[1]) +} diff --git a/crates/perps-tui/src/lib.rs b/crates/perps-tui/src/lib.rs index fafa55b..6571a4a 100644 --- a/crates/perps-tui/src/lib.rs +++ b/crates/perps-tui/src/lib.rs @@ -1,5 +1,10 @@ +mod helpers; +mod layout; +mod state; +mod style; +mod widgets; + use std::{ - cmp::Ordering, io, time::{Duration, Instant}, }; @@ -9,24 +14,24 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use perps_app::{ConnectorScorecard, ConnectorTier, PerpsApp}; -use perps_connector_api::ConnectorHealth; -use perps_domain::FundingNormalized; -use perps_funding::{find_best_spreads, FundingSpreadOpportunity}; +use perps_app::PerpsApp; use ratatui::{ backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}, + widgets::{Block, Borders, Paragraph}, Terminal, }; use tokio::sync::watch; -/// Concurrency limit for funding scans (avoid hammering APIs). -const FUNDING_BATCH_SIZE: usize = 10; +use crate::state::DashState; -const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_FRAMES: &[&str] = &[ + "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", + "\u{2807}", "\u{280f}", +]; +const FUNDING_BATCH_SIZE: usize = 10; +const MAX_SPREAD_ASSETS: usize = 25; const LOGO: &[&str] = &[ r" ██████╗ █████╗ ██╗███╗ ██╗████████╗██████╗ ███████╗███████╗", @@ -37,22 +42,6 @@ const LOGO: &[&str] = &[ r" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝", ]; -/// Max rows shown in the funding spreads table. -const MAX_SPREAD_ROWS: usize = 25; - -/// All watch-channel data the dashboard reads each frame. -struct DashState { - health: Vec, - scorecard: Vec, - spreads: Vec, - funding_raw: Vec, - last_health_refresh: Option, - last_funding_refresh: Option, - funding_assets_scanned: usize, - funding_assets_total: usize, - search_query: String, -} - pub async fn run() -> anyhow::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); @@ -60,21 +49,16 @@ pub async fn run() -> anyhow::Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Animated loading during bootstrap + // --- Bootstrap with animated loading --- let boot_start = Instant::now(); let mut frame_idx: usize = 0; - - // Show first loading frame immediately draw_loading(&mut terminal, "Bootstrapping connectors...", frame_idx)?; - // Spawn bootstrap in background so we can animate let (boot_tx, mut boot_rx) = tokio::sync::oneshot::channel::>(); tokio::spawn(async move { - let result = PerpsApp::bootstrap().await; - let _ = boot_tx.send(result); + let _ = boot_tx.send(PerpsApp::bootstrap().await); }); - // Animate loading screen while bootstrap runs let app = loop { frame_idx = frame_idx.wrapping_add(1); let elapsed = boot_start.elapsed().as_secs(); @@ -87,47 +71,44 @@ pub async fn run() -> anyhow::Result<()> { }; draw_loading(&mut terminal, msg, frame_idx)?; - // Check for quit if event::poll(Duration::from_millis(80))? { if let Event::Key(key) = event::read()? { if key.code == KeyCode::Char('q') { - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; + cleanup(&mut terminal)?; return Ok(()); } } } - // Check if bootstrap finished match boot_rx.try_recv() { Ok(Ok(app)) => break app, Ok(Err(e)) => { - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; + cleanup(&mut terminal)?; return Err(e); } Err(tokio::sync::oneshot::error::TryRecvError::Empty) => continue, Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; + cleanup(&mut terminal)?; anyhow::bail!("bootstrap task panicked"); } } }; // --- Watch channels --- - let (health_tx, health_rx) = watch::channel::>(vec![]); - let (scorecard_tx, scorecard_rx) = watch::channel::>(vec![]); - let (spread_tx, spread_rx) = watch::channel::>(vec![]); - let (funding_raw_tx, funding_raw_rx) = watch::channel::>(vec![]); + let (health_tx, health_rx) = watch::channel(vec![]); + let (scorecard_tx, scorecard_rx) = watch::channel(vec![]); + let (spread_tx, spread_rx) = watch::channel(vec![]); + let (funding_raw_tx, funding_raw_rx) = watch::channel(vec![]); let (health_ts_tx, health_ts_rx) = watch::channel::>(None); let (funding_ts_tx, funding_ts_rx) = watch::channel::>(None); - let (funding_progress_tx, funding_progress_rx) = watch::channel::<(usize, usize)>((0, 0)); - - // --- Health + Scorecard poller (every 5s) --- + let (funding_progress_tx, funding_progress_rx) = watch::channel((0usize, 0usize)); + let (account_tx, account_rx) = watch::channel::>(None); + let (account_ts_tx, account_ts_rx) = watch::channel::>(None); + let (auth_tx, auth_rx) = watch::channel::>(vec![]); + let (cb_tx, cb_rx) = watch::channel::>(vec![]); + let (rate_tx, rate_rx) = watch::channel::>(vec![]); + + // --- Health + scorecard + CB + rate limits poller (every 5s) --- let app_health = app.clone(); tokio::spawn(async move { loop { @@ -138,15 +119,43 @@ pub async fn run() -> anyhow::Result<()> { if let Ok(rows) = app_health.connector_scorecard().await { let _ = scorecard_tx.send(rows); } + if let Ok(cbs) = app_health.ops_circuit_breakers().await { + let _ = cb_tx.send(cbs); + } + if let Ok(rates) = app_health.ops_rate_limits().await { + let _ = rate_tx.send(rates); + } tokio::time::sleep(Duration::from_secs(5)).await; } }); - // --- Funding poller (every 15s) — scans top assets by venue coverage --- + // --- Auth check poller (every 30s) --- + let app_auth = app.clone(); + tokio::spawn(async move { + loop { + if let Ok(checks) = app_auth.ops_auth_check().await { + let _ = auth_tx.send(checks); + } + tokio::time::sleep(Duration::from_secs(30)).await; + } + }); + + // --- Account state poller (every 10s) --- + let app_account = app.clone(); + tokio::spawn(async move { + loop { + if let Ok(acct) = app_account.account_state(None).await { + let _ = account_tx.send(Some(acct)); + let _ = account_ts_tx.send(Some(Instant::now())); + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + }); + + // --- Funding poller (every 15s) --- let app_funding = app.clone(); tokio::spawn(async move { loop { - // Discover assets and rank by how many venues list them (best spread potential) let candidates = app_funding.universe_list(); let mut venue_count: std::collections::HashMap = std::collections::HashMap::new(); @@ -162,13 +171,12 @@ pub async fn run() -> anyhow::Result<()> { ranked.sort_by(|a, b| b.1.cmp(&a.1)); let assets: Vec = ranked .into_iter() - .take(MAX_SPREAD_ROWS) + .take(MAX_SPREAD_ASSETS) .map(|(a, _)| a) .collect(); let total = assets.len(); let mut all_rows = Vec::new(); - // Scan in concurrent batches to avoid hammering APIs for (batch_idx, batch) in assets.chunks(FUNDING_BATCH_SIZE).enumerate() { let scanned_so_far = batch_idx * FUNDING_BATCH_SIZE; let _ = funding_progress_tx.send((scanned_so_far, total)); @@ -183,7 +191,6 @@ pub async fn run() -> anyhow::Result<()> { } for handle in handles { if let Ok(Ok(rows)) = handle.await { - // Exclude CCXT venue results from the TUI funding view all_rows.extend( rows.into_iter() .filter(|r| !r.venue_id.starts_with("ccxt:")), @@ -192,7 +199,7 @@ pub async fn run() -> anyhow::Result<()> { } } let _ = funding_progress_tx.send((total, total)); - let spreads = find_best_spreads(&all_rows); + let spreads = perps_funding::find_best_spreads(&all_rows); let _ = spread_tx.send(spreads); let _ = funding_raw_tx.send(all_rows); let _ = funding_ts_tx.send(Some(Instant::now())); @@ -200,196 +207,61 @@ pub async fn run() -> anyhow::Result<()> { } }); + // --- Main event loop --- let mut should_quit = false; let mut cycle: u64 = 0; let mut search_query = String::new(); while !should_quit { cycle = cycle.saturating_add(1); - let (scanned, total) = *funding_progress_rx.borrow(); + let (scanned, total_assets) = *funding_progress_rx.borrow(); - let state = DashState { + let dash_state = DashState { health: health_rx.borrow().clone(), scorecard: scorecard_rx.borrow().clone(), spreads: spread_rx.borrow().clone(), funding_raw: funding_raw_rx.borrow().clone(), + account: account_rx.borrow().clone(), + auth_status: auth_rx.borrow().clone(), + circuit_breakers: cb_rx.borrow().clone(), + rate_limits: rate_rx.borrow().clone(), last_health_refresh: *health_ts_rx.borrow(), last_funding_refresh: *funding_ts_rx.borrow(), + last_account_refresh: *account_ts_rx.borrow(), funding_assets_scanned: scanned, - funding_assets_total: total, + funding_assets_total: total_assets, search_query: search_query.clone(), + cycle, }; - draw_dashboard(&mut terminal, &state, cycle)?; + draw_dashboard(&mut terminal, &dash_state)?; if event::poll(Duration::from_millis(200))? { if let Event::Key(key) = event::read()? { match key.code { - KeyCode::Char('q') if search_query.is_empty() => { - should_quit = true; - } - KeyCode::Esc => { - search_query.clear(); - } + KeyCode::Char('q') if search_query.is_empty() => should_quit = true, + KeyCode::Esc => search_query.clear(), KeyCode::Backspace => { search_query.pop(); } - KeyCode::Char(c) => { - search_query.push(c); - } + KeyCode::Char(c) => search_query.push(c), _ => {} } } } } + cleanup(&mut terminal)?; + Ok(()) +} + +fn cleanup(terminal: &mut Terminal>) -> io::Result<()> { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) } -// ─── Display helpers ───────────────────────────────────────────────────────── - -fn display_name(connector_id: &str) -> String { - let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); - let mut chars = raw.chars(); - match chars.next() { - Some(c) => { - let first = c.to_uppercase().to_string(); - format!("{first}{}", chars.as_str()) - } - None => raw.to_string(), - } -} - -fn display_market(market_key: &str) -> String { - let parts: Vec<&str> = market_key.split(':').collect(); - if parts.len() >= 3 { - let base = parts[1].to_uppercase(); - let quote = parts[2].to_uppercase(); - format!("{base}/{quote}") - } else { - market_key.to_uppercase() - } -} - -fn tier_style(tier: &ConnectorTier) -> (String, Color) { - match tier { - ConnectorTier::A => ("A".to_string(), Color::Green), - ConnectorTier::B => ("B".to_string(), Color::Yellow), - ConnectorTier::C => ("C".to_string(), Color::Red), - } -} - -fn caps_flags(caps: &perps_connector_api::ConnectorCapabilities) -> String { - let mut flags = String::new(); - if caps.can_stream { - flags.push('S'); - } - if caps.can_read_account { - flags.push('R'); - } - if caps.can_trade { - flags.push('T'); - } - if caps.can_reconcile { - flags.push('X'); - } - if caps.supports_funding_history { - flags.push('F'); - } - flags -} - -fn latency_bar(ms: u64, max_ms: u64, width: usize) -> Vec> { - if width == 0 || max_ms == 0 { - return vec![Span::raw(" ".repeat(width))]; - } - let ratio = (ms as f64 / max_ms as f64).clamp(0.0, 1.0); - let filled = (ratio * width as f64).round() as usize; - let filled = filled.min(width); - - let color = if ms < 300 { - Color::Green - } else if ms < 600 { - Color::Yellow - } else { - Color::Red - }; - - vec![ - Span::styled("▐".repeat(filled), Style::default().fg(color)), - Span::styled( - "·".repeat(width.saturating_sub(filled)), - Style::default().fg(Color::DarkGray), - ), - ] -} - -fn edge_meter(value: f64, max: f64, width: usize) -> String { - if width == 0 { - return String::new(); - } - if max <= 0.0 || value <= 0.0 { - return "·".repeat(width); - } - let ratio = (value / max).clamp(0.0, 1.0); - let filled = (ratio * width as f64).round() as usize; - let filled = filled.min(width); - format!("{}{}", "█".repeat(filled), "·".repeat(width - filled)) -} - -fn truncate_text(input: &str, width: usize) -> String { - if input.chars().count() <= width { - return input.to_string(); - } - let clipped = input - .chars() - .take(width.saturating_sub(1)) - .collect::(); - format!("{clipped}…") -} - -/// Format a percentage value for human-readable display. -fn format_rate(pct: f64) -> String { - let abs = pct.abs(); - let sign = if pct >= 0.0 { "+" } else { "-" }; - if abs >= 1_000_000.0 { - format!("{sign}{:.1}M%", abs / 1_000_000.0) - } else if abs >= 1_000.0 { - format!("{sign}{:.1}K%", abs / 1_000.0) - } else if abs >= 100.0 { - format!("{sign}{:.0}%", abs) - } else if abs >= 1.0 { - format!("{sign}{:.2}%", abs) - } else { - format!("{sign}{:.4}%", abs) - } -} - -fn ago_text(instant: Option) -> String { - match instant { - Some(ts) => { - let secs = ts.elapsed().as_secs(); - if secs < 2 { - "just now".to_string() - } else if secs < 60 { - format!("{secs}s ago") - } else { - format!("{}m ago", secs / 60) - } - } - None => "pending".to_string(), - } -} - -fn spinner(cycle: u64) -> &'static str { - SPINNER_FRAMES[(cycle as usize) % SPINNER_FRAMES.len()] -} - -// ─── Loading screen ────────────────────────────────────────────────────────── - fn draw_loading( terminal: &mut Terminal>, message: &str, @@ -399,21 +271,21 @@ fn draw_loading( let spin = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]; terminal.draw(|f| { let area = f.area(); - let chunks = Layout::default() - .direction(Direction::Vertical) + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) .constraints([ - Constraint::Percentage(25), - Constraint::Length(12), - Constraint::Percentage(25), + ratatui::layout::Constraint::Percentage(25), + ratatui::layout::Constraint::Length(12), + ratatui::layout::Constraint::Percentage(25), ]) .split(area); - let center = Layout::default() - .direction(Direction::Horizontal) + let center = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) .constraints([ - Constraint::Percentage(15), - Constraint::Percentage(70), - Constraint::Percentage(15), + ratatui::layout::Constraint::Percentage(15), + ratatui::layout::Constraint::Percentage(70), + ratatui::layout::Constraint::Percentage(15), ]) .split(chunks[1]); @@ -434,7 +306,6 @@ fn draw_loading( ), Span::styled(msg, Style::default().fg(Color::Yellow)), ])); - lines.push(Line::from("")); let loading = Paragraph::new(lines).block( Block::default() @@ -446,776 +317,23 @@ fn draw_loading( Ok(()) } -// ─── Dashboard ─────────────────────────────────────────────────────────────── - fn draw_dashboard( terminal: &mut Terminal>, state: &DashState, - cycle: u64, ) -> io::Result<()> { - let loading_health = state.health.is_empty(); - let loading_funding = state.spreads.is_empty(); - - let max_latency = state - .health - .iter() - .map(|row| row.latency_ms) - .max() - .unwrap_or(1000); - - // Build scorecard lookup - let scorecard_map: std::collections::HashMap<&str, &ConnectorScorecard> = state - .scorecard - .iter() - .map(|s| (s.connector_id.as_str(), s)) - .collect(); - terminal.draw(|f| { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // header - Constraint::Min(12), // body - Constraint::Length(3), // footer - ]) - .split(f.area()); - - draw_header(f, chunks[0], state, cycle, loading_health, loading_funding); - draw_body( - f, - chunks[1], - state, - cycle, - &scorecard_map, - max_latency, - loading_health, - loading_funding, - ); - draw_footer(f, chunks[2], state, cycle); + let (header_area, body_area, footer_area) = layout::main_layout(f.area()); + let (left_col, right_col) = layout::body_columns(body_area); + let (exchanges_area, creds_area, stats_area) = layout::left_panels(left_col); + let (funding_area, account_area) = layout::right_panels(right_col); + + widgets::header::draw(f, header_area, state); + widgets::exchanges::draw(f, exchanges_area, state); + widgets::credentials::draw(f, creds_area, state); + widgets::stats::draw(f, stats_area, state); + widgets::funding::draw(f, funding_area, state); + widgets::account::draw(f, account_area, state); + widgets::footer::draw(f, footer_area, state); })?; Ok(()) } - -fn draw_header( - f: &mut ratatui::Frame, - area: Rect, - state: &DashState, - cycle: u64, - loading_health: bool, - loading_funding: bool, -) { - let healthy = state.health.iter().filter(|r| r.ok).count(); - let total = state.health.len(); - let spin = spinner(cycle); - - let mut spans = vec![ - Span::styled( - " RAINTREE PERPS ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - ]; - - if loading_health && loading_funding { - spans.push(Span::styled( - format!("{spin} initializing..."), - Style::default().fg(Color::Yellow), - )); - } else { - let health_color = if healthy == total { - Color::Green - } else { - Color::Yellow - }; - spans.push(Span::styled( - format!("{healthy}/{total} healthy"), - Style::default().fg(health_color), - )); - - spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); - - if loading_funding { - let pct = if state.funding_assets_total > 0 { - state.funding_assets_scanned * 100 / state.funding_assets_total - } else { - 0 - }; - spans.push(Span::styled( - format!("{spin} scanning rates {pct}%"), - Style::default().fg(Color::Yellow), - )); - } else { - let pos = state.spreads.iter().filter(|s| s.apr_delta > 0.0).count(); - spans.push(Span::styled( - format!("{pos} opportunities"), - Style::default().fg(if pos > 0 { - Color::Green - } else { - Color::DarkGray - }), - )); - - spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); - - // Count unique venues across all funding data - let unique_venues: std::collections::HashSet<&str> = state - .funding_raw - .iter() - .map(|r| r.venue_id.as_str()) - .collect(); - spans.push(Span::styled( - format!("{} venues", unique_venues.len()), - Style::default().fg(Color::White), - )); - } - - spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); - let unique_assets: std::collections::HashSet = state - .funding_raw - .iter() - .map(|r| r.market_uid.base.to_uppercase()) - .collect(); - spans.push(Span::styled( - format!("{} assets", unique_assets.len()), - Style::default().fg(Color::White), - )); - } - - let header = Paragraph::new(Line::from(spans)).block( - Block::default() - .borders(Borders::ALL) - .title(" Status ") - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(header, area); -} - -#[allow(clippy::too_many_arguments)] -fn draw_body( - f: &mut ratatui::Frame, - area: Rect, - state: &DashState, - cycle: u64, - scorecard_map: &std::collections::HashMap<&str, &ConnectorScorecard>, - max_latency: u64, - loading_health: bool, - loading_funding: bool, -) { - let body_cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) - .split(area); - - let left_rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) - .split(body_cols[0]); - - let right_rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) - .split(body_cols[1]); - - // --- Connector Health Table (with Tier + Capabilities) --- - draw_connector_table( - f, - left_rows[0], - state, - scorecard_map, - max_latency, - cycle, - loading_health, - ); - draw_network_summary(f, left_rows[1], state, max_latency, loading_health); - draw_funding_table(f, right_rows[0], state, cycle, loading_funding); - draw_funding_summary(f, right_rows[1], state, loading_funding); -} - -fn draw_connector_table( - f: &mut ratatui::Frame, - area: Rect, - state: &DashState, - scorecard_map: &std::collections::HashMap<&str, &ConnectorScorecard>, - max_latency: u64, - cycle: u64, - loading: bool, -) { - if loading { - let spin = spinner(cycle); - let placeholder = Paragraph::new(vec![ - Line::from(""), - Line::from(""), - Line::from(vec![Span::styled( - format!(" {spin} Loading connector data..."), - Style::default().fg(Color::Yellow), - )]), - ]) - .block( - Block::default() - .title(" Connectors ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(placeholder, area); - return; - } - - let rows = state.health.iter().map(|h| { - let name = display_name(&h.connector_id); - let (icon, color) = if h.ok { - ("OK", Color::Green) - } else { - ("DOWN", Color::Red) - }; - - // Tier + capabilities from scorecard - let (tier_str, tier_color, caps_str) = - if let Some(sc) = scorecard_map.get(h.connector_id.as_str()) { - let (t, c) = tier_style(&sc.tier); - (t, c, caps_flags(&sc.capabilities)) - } else { - ("-".to_string(), Color::DarkGray, "-".to_string()) - }; - - let latency_text = format!("{}ms", h.latency_ms); - let bar = latency_bar(h.latency_ms, max_latency, 8); - - let msg = h - .message - .as_deref() - .map(|m| truncate_text(m, 14)) - .unwrap_or_default(); - - Row::new(vec![ - Cell::from(name).style(Style::default().fg(Color::White)), - Cell::from(icon).style(Style::default().fg(color).add_modifier(Modifier::BOLD)), - Cell::from(tier_str) - .style(Style::default().fg(tier_color).add_modifier(Modifier::BOLD)), - Cell::from(caps_str).style(Style::default().fg(Color::Cyan)), - Cell::from(latency_text).style(Style::default().fg(Color::White)), - Cell::from(Line::from(bar)), - Cell::from(msg).style(Style::default().fg(Color::DarkGray)), - ]) - }); - - let table = Table::new( - rows, - [ - Constraint::Length(12), // Name - Constraint::Length(5), // Status - Constraint::Length(5), // Tier - Constraint::Length(6), // Caps - Constraint::Length(7), // Latency - Constraint::Length(8), // Bar - Constraint::Min(8), // Message - ], - ) - .header( - Row::new(vec!["Exchange", "Up", "Tier", "Caps", "Ping", "", "Info"]) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .bottom_margin(1), - ) - .block( - Block::default() - .title(" Connectors ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(table, area); -} - -fn draw_network_summary( - f: &mut ratatui::Frame, - area: Rect, - state: &DashState, - max_latency: u64, - loading: bool, -) { - let connector_count = state.health.len(); - let healthy_count = state.health.iter().filter(|r| r.ok).count(); - let degraded_count = connector_count.saturating_sub(healthy_count); - let avg_latency = if connector_count > 0 { - state.health.iter().map(|r| r.latency_ms).sum::() / connector_count as u64 - } else { - 0 - }; - let min_latency = state.health.iter().map(|r| r.latency_ms).min().unwrap_or(0); - - // Capabilities summary - let total_caps: Vec<&ConnectorScorecard> = state.scorecard.iter().collect(); - let stream_count = total_caps - .iter() - .filter(|s| s.capabilities.can_stream) - .count(); - let trade_count = total_caps - .iter() - .filter(|s| s.capabilities.can_trade) - .count(); - let recon_count = total_caps - .iter() - .filter(|s| s.capabilities.can_reconcile) - .count(); - let tier_a = total_caps - .iter() - .filter(|s| matches!(s.tier, ConnectorTier::A)) - .count(); - - let health_updated = ago_text(state.last_health_refresh); - - let mut lines = vec![Line::from("")]; - - if loading { - lines.push(Line::from(vec![Span::styled( - " Waiting for first health poll...", - Style::default().fg(Color::Yellow), - )])); - } else { - lines.extend([ - Line::from(vec![ - Span::styled(" Healthy ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{healthy_count}"), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("/{connector_count}"), - Style::default().fg(Color::DarkGray), - ), - ]), - Line::from(vec![ - Span::styled(" Degraded ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{degraded_count}"), - Style::default() - .fg(if degraded_count > 0 { - Color::Red - } else { - Color::Green - }) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::styled(" Tier-A ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{tier_a}"), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" ({stream_count}S {trade_count}T {recon_count}X)"), - Style::default().fg(Color::DarkGray), - ), - ]), - Line::from(vec![ - Span::styled(" Ping ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("{min_latency}"), Style::default().fg(Color::Green)), - Span::styled(" / ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("{avg_latency}"), Style::default().fg(Color::White)), - Span::styled(" / ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{max_latency}ms"), - Style::default().fg(if max_latency > 800 { - Color::Red - } else if max_latency > 500 { - Color::Yellow - } else { - Color::White - }), - ), - ]), - Line::from(vec![ - Span::styled(" Updated ", Style::default().fg(Color::DarkGray)), - Span::styled(health_updated, Style::default().fg(Color::DarkGray)), - ]), - ]); - } - - let summary = Paragraph::new(lines).block( - Block::default() - .title(" Network ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(summary, area); -} - -fn draw_funding_table( - f: &mut ratatui::Frame, - area: Rect, - state: &DashState, - cycle: u64, - loading: bool, -) { - if loading { - let spin = spinner(cycle); - let pct = if state.funding_assets_total > 0 { - state.funding_assets_scanned * 100 / state.funding_assets_total - } else { - 0 - }; - let bar_width = 20; - let filled = pct * bar_width / 100; - let progress_bar = format!( - "[{}{}] {pct}%", - "█".repeat(filled), - "░".repeat(bar_width - filled) - ); - - let placeholder = Paragraph::new(vec![ - Line::from(""), - Line::from(""), - Line::from(vec![Span::styled( - format!( - " {spin} Scanning {}/{} assets...", - state.funding_assets_scanned, state.funding_assets_total - ), - Style::default().fg(Color::Yellow), - )]), - Line::from(""), - Line::from(vec![Span::styled( - format!(" {progress_bar}"), - Style::default().fg(Color::Cyan), - )]), - ]) - .block( - Block::default() - .title(" Funding Spreads ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(placeholder, area); - return; - } - - // Filter by search query, then cap at MAX_SPREAD_ROWS - let screen_rows = area.height.saturating_sub(4) as usize; // borders + header + margin - let display_limit = MAX_SPREAD_ROWS.min(screen_rows); - let search_upper = state.search_query.to_uppercase(); - - let filtered: Vec<&FundingSpreadOpportunity> = state - .spreads - .iter() - .filter(|s| { - if search_upper.is_empty() { - return true; - } - let market = display_market(&s.market_key).to_uppercase(); - let short = s.short_venue.to_uppercase(); - let long = s.long_venue.to_uppercase(); - market.contains(&search_upper) - || short.contains(&search_upper) - || long.contains(&search_upper) - }) - .take(display_limit) - .collect(); - - let best_apr = filtered.first().map(|s| s.apr_delta).unwrap_or(0.0); - - let rows = filtered.iter().map(|s| { - let market = display_market(&s.market_key); - let short = display_name(&s.short_venue); - let long = display_name(&s.long_venue); - - let hourly_pct = s.rate_delta_hourly * 100.0; - let rate_8h = s.rate_delta_hourly * 8.0 * 100.0; - let apr_pct = s.apr_delta * 100.0; - - let apr_color = if apr_pct > 5.0 { - Color::Green - } else if apr_pct > 1.0 { - Color::Yellow - } else if apr_pct > 0.0 { - Color::White - } else { - Color::DarkGray - }; - - let edge = edge_meter(s.apr_delta, best_apr, 6); - - let direction = if s.apr_delta > 0.0 { "LONG/SHORT" } else { "-" }; - let dir_color = if s.apr_delta > 0.0 { - Color::Green - } else { - Color::DarkGray - }; - - Row::new(vec![ - Cell::from(market).style( - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - Cell::from(truncate_text(&short, 10)), - Cell::from(truncate_text(&long, 10)), - Cell::from(format_rate(hourly_pct)).style(Style::default().fg(Color::DarkGray)), - Cell::from(format_rate(rate_8h)).style(Style::default().fg(Color::White)), - Cell::from(format_rate(apr_pct)) - .style(Style::default().fg(apr_color).add_modifier(Modifier::BOLD)), - Cell::from(direction).style(Style::default().fg(dir_color)), - Cell::from(edge), - ]) - }); - - let table = Table::new( - rows, - [ - Constraint::Length(10), // Market - Constraint::Length(10), // Short - Constraint::Length(10), // Long - Constraint::Length(9), // Hourly - Constraint::Length(8), // 8h - Constraint::Length(8), // APR - Constraint::Length(11), // Direction - Constraint::Min(6), // Edge - ], - ) - .header( - Row::new(vec![ - "Market", "Short", "Long", "Hourly", "8h Rate", "APR", "Signal", "Edge", - ]) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .bottom_margin(1), - ) - .block( - Block::default() - .title(if search_upper.is_empty() { - format!( - " Funding Spreads (top {} of {}) ", - filtered.len(), - state.spreads.len() - ) - } else { - format!( - " Funding Spreads — \"{}\" ({} matches) ", - state.search_query, - filtered.len() - ) - }) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(table, area); -} - -fn draw_funding_summary(f: &mut ratatui::Frame, area: Rect, state: &DashState, loading: bool) { - let spread_count = state.spreads.len(); - let positive_edges = state.spreads.iter().filter(|s| s.apr_delta > 0.0).count(); - let negative_edges = state.spreads.iter().filter(|s| s.apr_delta < 0.0).count(); - let best_apr = state - .spreads - .first() - .map(|s| s.apr_delta * 100.0) - .unwrap_or(0.0); - let worst_apr = state - .spreads - .last() - .map(|s| s.apr_delta * 100.0) - .unwrap_or(0.0); - let median_apr = if spread_count > 0 { - let mut aprs: Vec = state.spreads.iter().map(|s| s.apr_delta).collect(); - aprs.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal)); - aprs[aprs.len() / 2] * 100.0 - } else { - 0.0 - }; - - let unique_venues: std::collections::HashSet<&str> = state - .spreads - .iter() - .flat_map(|s| [s.short_venue.as_str(), s.long_venue.as_str()]) - .collect(); - - let best_route = state - .spreads - .first() - .map(|s| { - format!( - "{} → short {} / long {}", - display_market(&s.market_key), - display_name(&s.short_venue), - display_name(&s.long_venue), - ) - }) - .unwrap_or_else(|| "waiting for data...".to_string()); - - let funding_updated = ago_text(state.last_funding_refresh); - - let lines = if loading { - vec![ - Line::from(""), - Line::from(vec![Span::styled( - " Scanning funding rates across exchanges...", - Style::default().fg(Color::Yellow), - )]), - ] - } else { - vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Opportunities ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{positive_edges}"), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" pos / {negative_edges} neg / {spread_count} total"), - Style::default().fg(Color::DarkGray), - ), - ]), - Line::from(vec![ - Span::styled(" Venues ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{}", unique_venues.len()), - Style::default().fg(Color::White), - ), - Span::styled( - format!( - " ({})", - unique_venues - .into_iter() - .map(display_name) - .collect::>() - .join(", ") - ), - Style::default().fg(Color::DarkGray), - ), - ]), - Line::from(vec![ - Span::styled(" APR range ", Style::default().fg(Color::DarkGray)), - Span::styled( - format_rate(best_apr), - Style::default() - .fg(if best_apr > 5.0 { - Color::Green - } else { - Color::White - }) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" / ", Style::default().fg(Color::DarkGray)), - Span::styled(format_rate(median_apr), Style::default().fg(Color::White)), - Span::styled(" / ", Style::default().fg(Color::DarkGray)), - Span::styled( - format_rate(worst_apr), - Style::default().fg(if worst_apr < 0.0 { - Color::Red - } else { - Color::DarkGray - }), - ), - ]), - Line::from(vec![ - Span::styled(" Best route ", Style::default().fg(Color::DarkGray)), - Span::styled(best_route, Style::default().fg(Color::Cyan)), - ]), - Line::from(vec![ - Span::styled(" Updated ", Style::default().fg(Color::DarkGray)), - Span::styled(funding_updated, Style::default().fg(Color::DarkGray)), - ]), - ] - }; - - let summary = Paragraph::new(lines).wrap(Wrap { trim: true }).block( - Block::default() - .title(" Summary ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(summary, area); -} - -fn draw_footer(f: &mut ratatui::Frame, area: Rect, state: &DashState, cycle: u64) { - let spin = spinner(cycle); - let is_scanning = state.funding_assets_scanned < state.funding_assets_total - && state.last_funding_refresh.is_none(); - - let mut spans = vec![]; - - // Search input display - if state.search_query.is_empty() { - spans.extend([ - Span::styled( - " / ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::styled("search", Style::default().fg(Color::DarkGray)), - Span::raw(" "), - Span::styled( - " q ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::styled("quit", Style::default().fg(Color::DarkGray)), - ]); - } else { - spans.extend([ - Span::styled( - " search: ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{}_", state.search_query), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - " esc ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::styled("clear", Style::default().fg(Color::DarkGray)), - ]); - } - - spans.push(Span::raw(" ")); - - if is_scanning { - spans.push(Span::styled( - format!("{spin} "), - Style::default().fg(Color::Yellow), - )); - } - - spans.extend([ - Span::styled( - format!("cycle {cycle}"), - Style::default().fg(Color::DarkGray), - ), - Span::raw(" "), - Span::styled( - format!("{} assets scanned", state.funding_assets_total), - Style::default().fg(Color::DarkGray), - ), - ]); - - let footer = Paragraph::new(Line::from(spans)).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - f.render_widget(footer, area); -} diff --git a/crates/perps-tui/src/state.rs b/crates/perps-tui/src/state.rs new file mode 100644 index 0000000..8ef1842 --- /dev/null +++ b/crates/perps-tui/src/state.rs @@ -0,0 +1,25 @@ +use perps_app::ConnectorScorecard; +use perps_connector_api::ConnectorHealth; +use perps_domain::{AccountState, FundingNormalized}; +use perps_funding::FundingSpreadOpportunity; +use std::time::Instant; + +/// All data the dashboard reads each frame — populated by watch channels. +#[allow(dead_code)] +pub struct DashState { + pub health: Vec, + pub scorecard: Vec, + pub spreads: Vec, + pub funding_raw: Vec, + pub account: Option, + pub auth_status: Vec, + pub circuit_breakers: Vec, + pub rate_limits: Vec, + pub last_health_refresh: Option, + pub last_funding_refresh: Option, + pub last_account_refresh: Option, + pub funding_assets_scanned: usize, + pub funding_assets_total: usize, + pub search_query: String, + pub cycle: u64, +} diff --git a/crates/perps-tui/src/style.rs b/crates/perps-tui/src/style.rs new file mode 100644 index 0000000..0248a13 --- /dev/null +++ b/crates/perps-tui/src/style.rs @@ -0,0 +1,67 @@ +use ratatui::style::{Color, Modifier, Style}; + +pub const TITLE: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD); +pub const HEADER: Style = Style::new().fg(Color::White).add_modifier(Modifier::BOLD); +#[allow(dead_code)] +pub const LABEL: Style = Style::new().fg(Color::DarkGray); +pub const BORDER: Style = Style::new().fg(Color::DarkGray); +pub const VALUE: Style = Style::new().fg(Color::White); +pub const VALUE_BOLD: Style = Style::new().fg(Color::White).add_modifier(Modifier::BOLD); +pub const GOOD: Style = Style::new().fg(Color::Green); +pub const GOOD_BOLD: Style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD); +pub const WARN: Style = Style::new().fg(Color::Yellow); +pub const WARN_BOLD: Style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); +pub const BAD: Style = Style::new().fg(Color::Red); +pub const BAD_BOLD: Style = Style::new().fg(Color::Red).add_modifier(Modifier::BOLD); +pub const DIM: Style = Style::new().fg(Color::DarkGray); +pub const CYAN: Style = Style::new().fg(Color::Cyan); + +pub fn tier_style(tier: &perps_app::ConnectorTier) -> (&'static str, Style) { + match tier { + perps_app::ConnectorTier::A => ("A", GOOD_BOLD), + perps_app::ConnectorTier::B => ("B", WARN_BOLD), + perps_app::ConnectorTier::C => ("C", DIM), + } +} + +pub fn latency_style(ms: u64) -> Style { + if ms < 100 { + GOOD + } else if ms < 300 { + WARN + } else { + BAD + } +} + +pub fn apr_style(pct: f64) -> Style { + if pct > 5.0 { + GOOD_BOLD + } else if pct > 1.0 { + WARN + } else if pct > 0.0 { + VALUE + } else { + DIM + } +} + +pub fn pnl_style(val: f64) -> Style { + if val > 0.0 { + GOOD + } else if val < 0.0 { + BAD + } else { + DIM + } +} + +pub fn margin_style(pct: f64) -> Style { + if pct > 80.0 { + BAD + } else if pct > 50.0 { + WARN + } else { + GOOD + } +} diff --git a/crates/perps-tui/src/widgets/account.rs b/crates/perps-tui/src/widgets/account.rs new file mode 100644 index 0000000..f6ce5a9 --- /dev/null +++ b/crates/perps-tui/src/widgets/account.rs @@ -0,0 +1,109 @@ +use crate::{helpers::display_name, state::DashState, style}; +use ratatui::{ + layout::{Constraint, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let block = Block::default() + .title(" ACCOUNT ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + let account = match &state.account { + Some(a) => a, + None => { + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " No account data (set exchange credentials)", + style::DIM, + )), + ]) + .block(block); + f.render_widget(p, area); + return; + } + }; + + if account.balances.is_empty() { + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled(" No balances found", style::DIM)), + ]) + .block(block); + f.render_widget(p, area); + return; + } + + let mut venue_equity: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for b in &account.balances { + *venue_equity.entry(b.venue_id.clone()).or_default() += b.total; + } + + let mut venue_pnl: std::collections::HashMap = std::collections::HashMap::new(); + for p in &account.positions { + *venue_pnl.entry(p.venue_id.clone()).or_default() += p.unrealized_pnl; + } + + let mut data_rows: Vec = venue_equity + .iter() + .map(|(venue, &equity)| { + let pnl = venue_pnl.get(venue).copied().unwrap_or(0.0); + let margin_pct = if equity > 0.0 { + let venue_exposure: f64 = account + .positions + .iter() + .filter(|p| &p.venue_id == venue) + .map(|p| (p.size * p.mark_price).abs()) + .sum(); + venue_exposure / equity * 100.0 + } else { + 0.0 + }; + + let name = display_name(venue); + Row::new(vec![ + Cell::from(name).style(style::VALUE), + Cell::from(format!("${equity:.0}")).style(style::VALUE), + Cell::from(format!("{pnl:+.0}")).style(style::pnl_style(pnl)), + Cell::from(format!("{margin_pct:.0}%")).style(style::margin_style(margin_pct)), + ]) + }) + .collect(); + + let total_pnl: f64 = account.positions.iter().map(|p| p.unrealized_pnl).sum(); + let total_margin = if account.total_equity > 0.0 { + account.total_exposure_gross / account.total_equity * 100.0 + } else { + 0.0 + }; + data_rows.push(Row::new(vec![ + Cell::from("Total").style(style::VALUE_BOLD), + Cell::from(format!("${:.0}", account.total_equity)).style(style::VALUE_BOLD), + Cell::from(format!("{total_pnl:+.0}")).style(style::pnl_style(total_pnl)), + Cell::from(format!("{total_margin:.0}%")).style(style::margin_style(total_margin)), + ])); + + let table = Table::new( + data_rows, + [ + Constraint::Min(12), + Constraint::Length(10), + Constraint::Length(9), + Constraint::Length(7), + ], + ) + .header( + Row::new(vec!["Venue", "Equity", "Unrl P&L", "Margin"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block(block); + + f.render_widget(table, area); +} diff --git a/crates/perps-tui/src/widgets/credentials.rs b/crates/perps-tui/src/widgets/credentials.rs new file mode 100644 index 0000000..b8916b1 --- /dev/null +++ b/crates/perps-tui/src/widgets/credentials.rs @@ -0,0 +1,105 @@ +use crate::{helpers::display_name, state::DashState, style}; +use ratatui::{ + layout::{Constraint, Rect}, + widgets::{Block, Borders, Cell, Row, Table}, + Frame, +}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let block = Block::default() + .title(" CREDENTIALS & TRADING ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + let scorecard_map: std::collections::HashMap<&str, &perps_app::ConnectorScorecard> = state + .scorecard + .iter() + .map(|s| (s.connector_id.as_str(), s)) + .collect(); + + let auth_map: std::collections::HashMap = state + .auth_status + .iter() + .filter_map(|v| { + let id = v.get("connector_id")?.as_str()?.to_string(); + let st = v.get("auth_status")?.as_str()?.to_string(); + Some((id, st)) + }) + .collect(); + + let rate_map: std::collections::HashMap = state + .rate_limits + .iter() + .filter_map(|v| { + let id = v.get("connector_id")?.as_str()?.to_string(); + let status = v + .get("rate_limit_budget") + .and_then(|b| b.get("status")) + .and_then(|s| s.as_str()) + .unwrap_or("unknown") + .to_string(); + Some((id, status)) + }) + .collect(); + + let rows = state.health.iter().map(|h| { + let name = display_name(&h.connector_id); + + let auth = auth_map + .get(&h.connector_id) + .map(|s| s.as_str()) + .unwrap_or("unknown"); + let (keys_text, keys_sty) = match auth { + "valid" => ("\u{2713} set", style::GOOD), + "degraded" => ("~ deg", style::WARN), + _ => ("\u{2717} none", style::DIM), + }; + + let sc = scorecard_map.get(h.connector_id.as_str()); + let (trade_text, trade_sty) = if let Some(sc) = sc { + if sc.execution_enabled { + let (t, s) = style::tier_style(&sc.tier); + (format!("\u{2713} {t}"), s) + } else { + ("\u{2717}".to_string(), style::DIM) + } + } else { + ("?".to_string(), style::DIM) + }; + + let rate_status = rate_map + .get(&h.connector_id) + .map(|s| s.as_str()) + .unwrap_or("unknown"); + let (rate_text, rate_sty) = match rate_status { + "unknown" => ("\u{2014}".to_string(), style::DIM), + other => (other.to_string(), style::VALUE), + }; + + Row::new(vec![ + Cell::from(name).style(style::VALUE), + Cell::from(keys_text).style(keys_sty), + Cell::from(trade_text).style(trade_sty), + Cell::from(rate_text).style(rate_sty), + ]) + }); + + let table = Table::new( + rows, + [ + Constraint::Min(12), + Constraint::Length(7), + Constraint::Length(6), + Constraint::Length(8), + ], + ) + .header( + Row::new(vec!["Exchange", "Keys", "Trade", "Rate"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block(block); + + f.render_widget(table, area); +} diff --git a/crates/perps-tui/src/widgets/exchanges.rs b/crates/perps-tui/src/widgets/exchanges.rs new file mode 100644 index 0000000..80d0ee5 --- /dev/null +++ b/crates/perps-tui/src/widgets/exchanges.rs @@ -0,0 +1,104 @@ +use crate::{helpers::display_name, state::DashState, style}; +use ratatui::{ + layout::{Constraint, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; + +const SPINNER_FRAMES: &[&str] = &[ + "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", + "\u{2807}", "\u{280f}", +]; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let block = Block::default() + .title(" EXCHANGES ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + if state.health.is_empty() { + let spin = SPINNER_FRAMES[(state.cycle as usize) % SPINNER_FRAMES.len()]; + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" {spin} Loading connectors..."), + style::WARN, + )), + ]) + .block(block); + f.render_widget(p, area); + return; + } + + let scorecard_map: std::collections::HashMap<&str, &perps_app::ConnectorScorecard> = state + .scorecard + .iter() + .map(|s| (s.connector_id.as_str(), s)) + .collect(); + + let cb_map: std::collections::HashMap = state + .circuit_breakers + .iter() + .filter_map(|v| { + let id = v.get("connector_id")?.as_str()?.to_string(); + let st = v.get("state")?.as_str()?.to_string(); + Some((id, st)) + }) + .collect(); + + let rows = state.health.iter().map(|h| { + let name = display_name(&h.connector_id); + let (status_text, status_style) = if h.ok { + ("OK", style::GOOD_BOLD) + } else { + ("DOWN", style::BAD_BOLD) + }; + + let (tier_text, tier_sty) = scorecard_map + .get(h.connector_id.as_str()) + .map(|sc| style::tier_style(&sc.tier)) + .unwrap_or(("-", style::DIM)); + + let ping = format!("{}ms", h.latency_ms); + let ping_sty = style::latency_style(h.latency_ms); + + let cb_state = cb_map + .get(&h.connector_id) + .map(|s| s.as_str()) + .unwrap_or("?"); + let (cb_text, cb_sty) = match cb_state { + "closed" => ("\u{2713}", style::GOOD), + "open" => ("\u{2717}", style::BAD), + _ => ("?", style::DIM), + }; + + Row::new(vec![ + Cell::from(name).style(style::VALUE), + Cell::from(status_text).style(status_style), + Cell::from(tier_text).style(tier_sty), + Cell::from(ping).style(ping_sty), + Cell::from(cb_text).style(cb_sty), + ]) + }); + + let table = Table::new( + rows, + [ + Constraint::Min(12), + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(7), + Constraint::Length(3), + ], + ) + .header( + Row::new(vec!["Exchange", "\u{25cf}", "Tier", "Ping", "CB"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block(block); + + f.render_widget(table, area); +} diff --git a/crates/perps-tui/src/widgets/footer.rs b/crates/perps-tui/src/widgets/footer.rs new file mode 100644 index 0000000..1c1920a --- /dev/null +++ b/crates/perps-tui/src/widgets/footer.rs @@ -0,0 +1,36 @@ +use crate::{state::DashState, style}; +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let mut spans = vec![]; + + if state.search_query.is_empty() { + spans.extend([ + Span::styled(" /", style::CYAN), + Span::styled(" search ", style::DIM), + Span::styled("q", style::CYAN), + Span::styled(" quit", style::DIM), + ]); + } else { + spans.extend([ + Span::styled(" search: ", style::CYAN), + Span::styled(format!("{}_", state.search_query), style::VALUE_BOLD), + Span::raw(" "), + Span::styled("esc", style::CYAN), + Span::styled(" clear", style::DIM), + ]); + } + + let used: usize = spans.iter().map(|s| s.content.len()).sum(); + let remaining = (area.width as usize).saturating_sub(used + 12); + spans.push(Span::raw(" ".repeat(remaining))); + spans.push(Span::styled(format!("cycle {}", state.cycle), style::DIM)); + spans.push(Span::raw(" ")); + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} diff --git a/crates/perps-tui/src/widgets/funding.rs b/crates/perps-tui/src/widgets/funding.rs new file mode 100644 index 0000000..c05115a --- /dev/null +++ b/crates/perps-tui/src/widgets/funding.rs @@ -0,0 +1,124 @@ +use crate::{ + helpers::{display_market, display_name, format_rate, truncate}, + state::DashState, + style, +}; +use ratatui::{ + layout::{Constraint, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; + +const SPINNER_FRAMES: &[&str] = &[ + "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", + "\u{2807}", "\u{280f}", +]; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let block = Block::default() + .title(" FUNDING OPPORTUNITIES ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + if state.spreads.is_empty() && state.last_funding_refresh.is_none() { + let spin = SPINNER_FRAMES[(state.cycle as usize) % SPINNER_FRAMES.len()]; + let pct = if state.funding_assets_total > 0 { + state.funding_assets_scanned * 100 / state.funding_assets_total + } else { + 0 + }; + let p = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!( + " {spin} Scanning {}/{} assets... {pct}%", + state.funding_assets_scanned, state.funding_assets_total + ), + style::WARN, + )), + ]) + .block(block); + f.render_widget(p, area); + return; + } + + let screen_rows = area.height.saturating_sub(4) as usize; + let search_upper = state.search_query.to_uppercase(); + + let filtered: Vec<_> = state + .spreads + .iter() + .filter(|s| { + if search_upper.is_empty() { + return true; + } + let market = display_market(&s.market_key).to_uppercase(); + let short = s.short_venue.to_uppercase(); + let long = s.long_venue.to_uppercase(); + market.contains(&search_upper) + || short.contains(&search_upper) + || long.contains(&search_upper) + }) + .take(screen_rows) + .collect(); + + let rows = filtered.iter().map(|s| { + let market = display_market(&s.market_key); + let short = display_name(&s.short_venue); + let long = display_name(&s.long_venue); + let apr_pct = s.apr_delta * 100.0; + let apr_text = format_rate(apr_pct); + + let signal = if s.apr_delta > 0.0 { "BUY" } else { "\u{2014}" }; + let sig_sty = if s.apr_delta > 0.0 { + style::GOOD + } else { + style::DIM + }; + + Row::new(vec![ + Cell::from(market).style(style::VALUE_BOLD), + Cell::from(truncate(&short, 10)).style(style::VALUE), + Cell::from(truncate(&long, 10)).style(style::VALUE), + Cell::from(apr_text).style(style::apr_style(apr_pct)), + Cell::from(signal).style(sig_sty), + ]) + }); + + let title = if search_upper.is_empty() { + format!(" FUNDING OPPORTUNITIES ({}) ", state.spreads.len()) + } else { + format!( + " FUNDING \u{2014} \"{}\" ({} matches) ", + state.search_query, + filtered.len() + ) + }; + + let table = Table::new( + rows, + [ + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(9), + Constraint::Min(4), + ], + ) + .header( + Row::new(vec!["Market", "Short", "Long", "APR", "Sig"]) + .style(style::HEADER) + .bottom_margin(1), + ) + .block( + Block::default() + .title(title) + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER), + ); + + f.render_widget(table, area); +} diff --git a/crates/perps-tui/src/widgets/header.rs b/crates/perps-tui/src/widgets/header.rs new file mode 100644 index 0000000..015208c --- /dev/null +++ b/crates/perps-tui/src/widgets/header.rs @@ -0,0 +1,38 @@ +use crate::{state::DashState, style}; +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let healthy = state.health.iter().filter(|h| h.ok).count(); + let total = state.health.len(); + let opps = state.spreads.iter().filter(|s| s.apr_delta > 0.0).count(); + let now = chrono::Local::now().format("%H:%M %p").to_string(); + + let health_style = if healthy == total && total > 0 { + style::GOOD + } else if total == 0 { + style::DIM + } else { + style::WARN + }; + + let line = Line::from(vec![ + Span::styled(" RAINTREE PERPS", style::TITLE), + Span::raw(" "), + Span::styled(format!("{healthy}/{total} up"), health_style), + Span::styled(" \u{00b7} ", style::DIM), + Span::styled( + format!("{opps} opps"), + if opps > 0 { style::GOOD } else { style::DIM }, + ), + Span::raw(" ".repeat(area.width.saturating_sub(40) as usize)), + Span::styled(now, style::DIM), + Span::raw(" "), + ]); + + f.render_widget(Paragraph::new(line), area); +} diff --git a/crates/perps-tui/src/widgets/mod.rs b/crates/perps-tui/src/widgets/mod.rs new file mode 100644 index 0000000..f1bf7ca --- /dev/null +++ b/crates/perps-tui/src/widgets/mod.rs @@ -0,0 +1,7 @@ +pub mod account; +pub mod credentials; +pub mod exchanges; +pub mod footer; +pub mod funding; +pub mod header; +pub mod stats; diff --git a/crates/perps-tui/src/widgets/stats.rs b/crates/perps-tui/src/widgets/stats.rs new file mode 100644 index 0000000..27ffebc --- /dev/null +++ b/crates/perps-tui/src/widgets/stats.rs @@ -0,0 +1,79 @@ +use crate::{state::DashState, style}; +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { + let opps = state.spreads.iter().filter(|s| s.apr_delta > 0.0).count(); + let unique_venues: std::collections::HashSet<&str> = state + .funding_raw + .iter() + .map(|r| r.venue_id.as_str()) + .collect(); + let unique_assets: std::collections::HashSet = state + .funding_raw + .iter() + .map(|r| r.market_uid.base.to_uppercase()) + .collect(); + + let best_apr = state + .spreads + .first() + .map(|s| s.apr_delta * 100.0) + .unwrap_or(0.0); + + let median_apr = if !state.spreads.is_empty() { + let mut aprs: Vec = state.spreads.iter().map(|s| s.apr_delta * 100.0).collect(); + aprs.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + aprs[aprs.len() / 2] + } else { + 0.0 + }; + + let tradeable = state + .scorecard + .iter() + .filter(|s| s.execution_enabled) + .count(); + let avg_ping = if !state.health.is_empty() { + state.health.iter().map(|h| h.latency_ms).sum::() / state.health.len() as u64 + } else { + 0 + }; + + let lines = vec![ + Line::from(vec![ + Span::styled( + format!(" {} opps", opps), + if opps > 0 { style::GOOD } else { style::DIM }, + ), + Span::styled( + format!( + " \u{00b7} {} venues \u{00b7} {} assets", + unique_venues.len(), + unique_assets.len() + ), + style::DIM, + ), + ]), + Line::from(vec![ + Span::styled(format!(" best {best_apr:+.1}%"), style::apr_style(best_apr)), + Span::styled(format!(" \u{00b7} median {median_apr:+.1}%"), style::DIM), + ]), + Line::from(vec![ + Span::styled(format!(" {tradeable} tradeable"), style::GOOD), + Span::styled(format!(" \u{00b7} avg ping {avg_ping}ms"), style::DIM), + ]), + ]; + + let block = Block::default() + .title(" STATS ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER); + + f.render_widget(Paragraph::new(lines).block(block), area); +} From 074bf184fb8d45786614a1d565c607e6067c192d Mon Sep 17 00:00:00 2001 From: Zachary Roth Date: Tue, 3 Mar 2026 13:24:17 -0800 Subject: [PATCH 4/6] fix(tui): code quality improvements from review - Add TerminalGuard for panic-safe terminal cleanup (Drop impl) - Consolidate SPINNER_FRAMES into helpers module, remove duplication - Fix time format from mixed %H:%M %p to %H:%M - Fix header padding to use dynamic width calculation - Fix dead block binding in funding widget loading state Co-Authored-By: Claude Opus 4.6 --- crates/perps-tui/src/helpers.rs | 9 +++++ crates/perps-tui/src/lib.rs | 42 ++++++++++------------- crates/perps-tui/src/widgets/exchanges.rs | 13 ++++--- crates/perps-tui/src/widgets/funding.rs | 23 +++++-------- crates/perps-tui/src/widgets/header.rs | 19 ++++++---- 5 files changed, 55 insertions(+), 51 deletions(-) diff --git a/crates/perps-tui/src/helpers.rs b/crates/perps-tui/src/helpers.rs index 1c29074..325846b 100644 --- a/crates/perps-tui/src/helpers.rs +++ b/crates/perps-tui/src/helpers.rs @@ -1,3 +1,12 @@ +pub const SPINNER_FRAMES: &[&str] = &[ + "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", + "\u{2807}", "\u{280f}", +]; + +pub fn spinner(cycle: u64) -> &'static str { + SPINNER_FRAMES[(cycle as usize) % SPINNER_FRAMES.len()] +} + pub fn display_name(connector_id: &str) -> String { let raw = connector_id.strip_prefix("ccxt:").unwrap_or(connector_id); let mut chars = raw.chars(); diff --git a/crates/perps-tui/src/lib.rs b/crates/perps-tui/src/lib.rs index 6571a4a..ac0629e 100644 --- a/crates/perps-tui/src/lib.rs +++ b/crates/perps-tui/src/lib.rs @@ -24,12 +24,19 @@ use ratatui::{ }; use tokio::sync::watch; -use crate::state::DashState; +use crate::{helpers::SPINNER_FRAMES, state::DashState}; + +/// Ensures terminal is restored to normal mode even on panic. +struct TerminalGuard(Terminal>); + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(self.0.backend_mut(), LeaveAlternateScreen); + let _ = self.0.show_cursor(); + } +} -const SPINNER_FRAMES: &[&str] = &[ - "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", - "\u{2807}", "\u{280f}", -]; const FUNDING_BATCH_SIZE: usize = 10; const MAX_SPREAD_ASSETS: usize = 25; @@ -47,12 +54,13 @@ pub async fn run() -> anyhow::Result<()> { let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + let terminal = Terminal::new(backend)?; + let mut guard = TerminalGuard(terminal); // --- Bootstrap with animated loading --- let boot_start = Instant::now(); let mut frame_idx: usize = 0; - draw_loading(&mut terminal, "Bootstrapping connectors...", frame_idx)?; + draw_loading(&mut guard.0, "Bootstrapping connectors...", frame_idx)?; let (boot_tx, mut boot_rx) = tokio::sync::oneshot::channel::>(); tokio::spawn(async move { @@ -69,12 +77,11 @@ pub async fn run() -> anyhow::Result<()> { } else { "Almost ready..." }; - draw_loading(&mut terminal, msg, frame_idx)?; + draw_loading(&mut guard.0, msg, frame_idx)?; if event::poll(Duration::from_millis(80))? { if let Event::Key(key) = event::read()? { if key.code == KeyCode::Char('q') { - cleanup(&mut terminal)?; return Ok(()); } } @@ -82,13 +89,9 @@ pub async fn run() -> anyhow::Result<()> { match boot_rx.try_recv() { Ok(Ok(app)) => break app, - Ok(Err(e)) => { - cleanup(&mut terminal)?; - return Err(e); - } + Ok(Err(e)) => return Err(e), Err(tokio::sync::oneshot::error::TryRecvError::Empty) => continue, Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { - cleanup(&mut terminal)?; anyhow::bail!("bootstrap task panicked"); } } @@ -234,7 +237,7 @@ pub async fn run() -> anyhow::Result<()> { cycle, }; - draw_dashboard(&mut terminal, &dash_state)?; + draw_dashboard(&mut guard.0, &dash_state)?; if event::poll(Duration::from_millis(200))? { if let Event::Key(key) = event::read()? { @@ -251,14 +254,7 @@ pub async fn run() -> anyhow::Result<()> { } } - cleanup(&mut terminal)?; - Ok(()) -} - -fn cleanup(terminal: &mut Terminal>) -> io::Result<()> { - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; + // TerminalGuard::drop handles cleanup automatically Ok(()) } diff --git a/crates/perps-tui/src/widgets/exchanges.rs b/crates/perps-tui/src/widgets/exchanges.rs index 80d0ee5..413fc09 100644 --- a/crates/perps-tui/src/widgets/exchanges.rs +++ b/crates/perps-tui/src/widgets/exchanges.rs @@ -1,4 +1,8 @@ -use crate::{helpers::display_name, state::DashState, style}; +use crate::{ + helpers::{display_name, spinner}, + state::DashState, + style, +}; use ratatui::{ layout::{Constraint, Rect}, text::{Line, Span}, @@ -6,11 +10,6 @@ use ratatui::{ Frame, }; -const SPINNER_FRAMES: &[&str] = &[ - "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", - "\u{2807}", "\u{280f}", -]; - pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { let block = Block::default() .title(" EXCHANGES ") @@ -19,7 +18,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { .border_style(style::BORDER); if state.health.is_empty() { - let spin = SPINNER_FRAMES[(state.cycle as usize) % SPINNER_FRAMES.len()]; + let spin = spinner(state.cycle); let p = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( diff --git a/crates/perps-tui/src/widgets/funding.rs b/crates/perps-tui/src/widgets/funding.rs index c05115a..970870e 100644 --- a/crates/perps-tui/src/widgets/funding.rs +++ b/crates/perps-tui/src/widgets/funding.rs @@ -1,5 +1,5 @@ use crate::{ - helpers::{display_market, display_name, format_rate, truncate}, + helpers::{display_market, display_name, format_rate, spinner, truncate}, state::DashState, style, }; @@ -10,20 +10,9 @@ use ratatui::{ Frame, }; -const SPINNER_FRAMES: &[&str] = &[ - "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", - "\u{2807}", "\u{280f}", -]; - pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { - let block = Block::default() - .title(" FUNDING OPPORTUNITIES ") - .title_style(style::HEADER) - .borders(Borders::ALL) - .border_style(style::BORDER); - if state.spreads.is_empty() && state.last_funding_refresh.is_none() { - let spin = SPINNER_FRAMES[(state.cycle as usize) % SPINNER_FRAMES.len()]; + let spin = spinner(state.cycle); let pct = if state.funding_assets_total > 0 { state.funding_assets_scanned * 100 / state.funding_assets_total } else { @@ -39,7 +28,13 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { style::WARN, )), ]) - .block(block); + .block( + Block::default() + .title(" FUNDING OPPORTUNITIES ") + .title_style(style::HEADER) + .borders(Borders::ALL) + .border_style(style::BORDER), + ); f.render_widget(p, area); return; } diff --git a/crates/perps-tui/src/widgets/header.rs b/crates/perps-tui/src/widgets/header.rs index 015208c..1a6b940 100644 --- a/crates/perps-tui/src/widgets/header.rs +++ b/crates/perps-tui/src/widgets/header.rs @@ -10,7 +10,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { let healthy = state.health.iter().filter(|h| h.ok).count(); let total = state.health.len(); let opps = state.spreads.iter().filter(|s| s.apr_delta > 0.0).count(); - let now = chrono::Local::now().format("%H:%M %p").to_string(); + let now = chrono::Local::now().format("%H:%M").to_string(); let health_style = if healthy == total && total > 0 { style::GOOD @@ -20,7 +20,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { style::WARN }; - let line = Line::from(vec![ + let left_spans = vec![ Span::styled(" RAINTREE PERPS", style::TITLE), Span::raw(" "), Span::styled(format!("{healthy}/{total} up"), health_style), @@ -29,10 +29,15 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { format!("{opps} opps"), if opps > 0 { style::GOOD } else { style::DIM }, ), - Span::raw(" ".repeat(area.width.saturating_sub(40) as usize)), - Span::styled(now, style::DIM), - Span::raw(" "), - ]); + ]; - f.render_widget(Paragraph::new(line), area); + let left_width: usize = left_spans.iter().map(|s| s.content.len()).sum(); + let right_text = format!("{now} "); + let padding = (area.width as usize).saturating_sub(left_width + right_text.len()); + + let mut spans = left_spans; + spans.push(Span::raw(" ".repeat(padding))); + spans.push(Span::styled(right_text, style::DIM)); + + f.render_widget(Paragraph::new(Line::from(spans)), area); } From 61cbf8f5e09d3eedd3cd13d923ff555010d93fdb Mon Sep 17 00:00:00 2001 From: Zachary Roth Date: Tue, 3 Mar 2026 15:15:21 -0800 Subject: [PATCH 5/6] fix(tui): show ERR for disconnected exchanges, always register Decibel, compact APR display - Change DOWN to ERR for disconnected exchange status - Always register Decibel connector so it appears in TUI even without credentials - Add T%/B% tiers to format_rate for compact display of large APR values - Use format_rate in stats widget instead of raw formatting - Widen funding APR column from 9 to 10 chars Co-Authored-By: Claude Opus 4.6 --- crates/perps-app/src/lib.rs | 29 ++++++++++------------- crates/perps-tui/src/helpers.rs | 14 +++++++++-- crates/perps-tui/src/widgets/exchanges.rs | 2 +- crates/perps-tui/src/widgets/funding.rs | 2 +- crates/perps-tui/src/widgets/stats.rs | 9 ++++--- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/crates/perps-app/src/lib.rs b/crates/perps-app/src/lib.rs index e8b0b07..f2c0cfa 100644 --- a/crates/perps-app/src/lib.rs +++ b/crates/perps-app/src/lib.rs @@ -228,24 +228,19 @@ impl PerpsApp { ); registry.register(Arc::new(hl)); - // Decibel: mainnet requires a bearer token even for public endpoints. + // Decibel: always register so it appears in the TUI exchange list. // Accept both DECIBEL_BASE_URL and DECIBEL_REST_URL for the REST base. - let decibel_token = std::env::var("DECIBEL_API_BEARER_TOKEN").ok(); - if let Some(ref token) = decibel_token { - let base_url = std::env::var("DECIBEL_BASE_URL") - .or_else(|_| std::env::var("DECIBEL_REST_URL")) - .ok(); - let db = DecibelConnector::new( - base_url, - std::env::var("DECIBEL_WS_URL").ok(), - Some(token.clone()), - std::env::var("DECIBEL_WALLET_ADDRESS").ok(), - std::env::var("DECIBEL_PRIVATE_KEY").ok(), - ); - registry.register(Arc::new(db)); - } else { - warn!("DECIBEL_API_BEARER_TOKEN not set — skipping Decibel connector (mainnet requires auth)"); - } + let base_url = std::env::var("DECIBEL_BASE_URL") + .or_else(|_| std::env::var("DECIBEL_REST_URL")) + .ok(); + let db = DecibelConnector::new( + base_url, + std::env::var("DECIBEL_WS_URL").ok(), + std::env::var("DECIBEL_API_BEARER_TOKEN").ok(), + std::env::var("DECIBEL_WALLET_ADDRESS").ok(), + std::env::var("DECIBEL_PRIVATE_KEY").ok(), + ); + registry.register(Arc::new(db)); // CCXT-native exchanges: read per-exchange credentials from env for exchange_id in ccxt_exchange_ids() { diff --git a/crates/perps-tui/src/helpers.rs b/crates/perps-tui/src/helpers.rs index 325846b..2a279f8 100644 --- a/crates/perps-tui/src/helpers.rs +++ b/crates/perps-tui/src/helpers.rs @@ -34,15 +34,25 @@ pub fn truncate(input: &str, width: usize) -> String { } } +/// Format a percentage value into a compact human-readable string (max ~8 chars). pub fn format_rate(pct: f64) -> String { let abs = pct.abs(); let sign = if pct >= 0.0 { "+" } else { "-" }; - if abs >= 1_000_000.0 { - format!("{sign}{:.1}M%", abs / 1_000_000.0) + // Progressively shorten: T% → B% → M% → K% → plain % + if abs >= 1_000_000_000_000.0 { + format!("{sign}{:.0}T%", abs / 1_000_000_000_000.0) + } else if abs >= 1_000_000_000.0 { + format!("{sign}{:.0}B%", abs / 1_000_000_000.0) + } else if abs >= 1_000_000.0 { + format!("{sign}{:.0}M%", abs / 1_000_000.0) + } else if abs >= 10_000.0 { + format!("{sign}{:.0}K%", abs / 1_000.0) } else if abs >= 1_000.0 { format!("{sign}{:.1}K%", abs / 1_000.0) } else if abs >= 100.0 { format!("{sign}{:.0}%", abs) + } else if abs >= 10.0 { + format!("{sign}{:.1}%", abs) } else if abs >= 1.0 { format!("{sign}{:.2}%", abs) } else { diff --git a/crates/perps-tui/src/widgets/exchanges.rs b/crates/perps-tui/src/widgets/exchanges.rs index 413fc09..38c191b 100644 --- a/crates/perps-tui/src/widgets/exchanges.rs +++ b/crates/perps-tui/src/widgets/exchanges.rs @@ -52,7 +52,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { let (status_text, status_style) = if h.ok { ("OK", style::GOOD_BOLD) } else { - ("DOWN", style::BAD_BOLD) + ("ERR", style::BAD_BOLD) }; let (tier_text, tier_sty) = scorecard_map diff --git a/crates/perps-tui/src/widgets/funding.rs b/crates/perps-tui/src/widgets/funding.rs index 970870e..b3e8027 100644 --- a/crates/perps-tui/src/widgets/funding.rs +++ b/crates/perps-tui/src/widgets/funding.rs @@ -98,7 +98,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { Constraint::Length(10), Constraint::Length(10), Constraint::Length(10), - Constraint::Length(9), + Constraint::Length(10), Constraint::Min(4), ], ) diff --git a/crates/perps-tui/src/widgets/stats.rs b/crates/perps-tui/src/widgets/stats.rs index 27ffebc..999282e 100644 --- a/crates/perps-tui/src/widgets/stats.rs +++ b/crates/perps-tui/src/widgets/stats.rs @@ -1,4 +1,4 @@ -use crate::{state::DashState, style}; +use crate::{helpers::format_rate, state::DashState, style}; use ratatui::{ layout::Rect, text::{Line, Span}, @@ -60,8 +60,11 @@ pub fn draw(f: &mut Frame, area: Rect, state: &DashState) { ), ]), Line::from(vec![ - Span::styled(format!(" best {best_apr:+.1}%"), style::apr_style(best_apr)), - Span::styled(format!(" \u{00b7} median {median_apr:+.1}%"), style::DIM), + Span::styled(format!(" best {}", format_rate(best_apr)), style::apr_style(best_apr)), + Span::styled( + format!(" \u{00b7} median {}", format_rate(median_apr)), + style::DIM, + ), ]), Line::from(vec![ Span::styled(format!(" {tradeable} tradeable"), style::GOOD), From 15e193d77c5ca596c6b6bcb9355171c6dfcd58bd Mon Sep 17 00:00:00 2001 From: Zachary Roth Date: Tue, 3 Mar 2026 15:16:25 -0800 Subject: [PATCH 6/6] chore: sync workspace changes (deps, website, decibel connector) Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 100 +- Cargo.toml | 1 + README.md | 12 +- crates/perps-connectors-decibel/Cargo.toml | 3 + crates/perps-connectors-decibel/src/lib.rs | 1245 ++++++++++++++++++-- website/app/(marketing)/page.tsx | 339 +++++- website/app/globals.css | 2 +- website/lib/agent-manifest.ts | 4 +- website/next-env.d.ts | 2 +- website/public/.well-known/eip-8004.json | 4 +- website/public/agents.json | 4 +- website/public/llms.txt | 15 +- 12 files changed, 1559 insertions(+), 172 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2bf3368..10e5815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,16 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b6598a2f5d564fb7855dc6b06fd1c38cff5a72bd8b863a4d021938497b440a" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -1595,7 +1605,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perps-app" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -1625,7 +1635,7 @@ dependencies = [ [[package]] name = "perps-cli" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -1645,7 +1655,7 @@ dependencies = [ [[package]] name = "perps-connector-api" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", @@ -1654,13 +1664,13 @@ dependencies = [ "perps-domain", "schemars", "serde", - "thiserror", + "thiserror 2.0.18", "tracing", ] [[package]] name = "perps-connectors-aevo" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1684,7 +1694,7 @@ dependencies = [ [[package]] name = "perps-connectors-ccxt" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1708,11 +1718,13 @@ dependencies = [ [[package]] name = "perps-connectors-decibel" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", + "bcs", "chrono", + "ed25519-dalek", "futures", "hex", "perps-connector-api", @@ -1720,6 +1732,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha3", "tokio", "tokio-tungstenite", "tracing", @@ -1729,7 +1742,7 @@ dependencies = [ [[package]] name = "perps-connectors-dydx" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1749,7 +1762,7 @@ dependencies = [ [[package]] name = "perps-connectors-gmx" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "chrono", @@ -1767,7 +1780,7 @@ dependencies = [ [[package]] name = "perps-connectors-hyperliquid" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1794,7 +1807,7 @@ dependencies = [ [[package]] name = "perps-connectors-orderly" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1817,7 +1830,7 @@ dependencies = [ [[package]] name = "perps-connectors-paradex" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1838,7 +1851,7 @@ dependencies = [ [[package]] name = "perps-connectors-vertex" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1858,33 +1871,33 @@ dependencies = [ [[package]] name = "perps-domain" -version = "0.2.2" +version = "0.2.3" dependencies = [ "chrono", "schemars", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "uuid", ] [[package]] name = "perps-execution" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", "chrono", "perps-domain", "serde", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "perps-funding" -version = "0.2.2" +version = "0.2.3" dependencies = [ "chrono", "perps-domain", @@ -1894,7 +1907,7 @@ dependencies = [ [[package]] name = "perps-gateway" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "axum", @@ -1910,17 +1923,17 @@ dependencies = [ [[package]] name = "perps-market-graph" -version = "0.2.2" +version = "0.2.3" dependencies = [ "perps-domain", "schemars", "serde", - "thiserror", + "thiserror 2.0.18", ] [[package]] name = "perps-observability" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "tracing", @@ -1929,7 +1942,7 @@ dependencies = [ [[package]] name = "perps-risk" -version = "0.2.2" +version = "0.2.3" dependencies = [ "chrono", "perps-domain", @@ -1938,7 +1951,7 @@ dependencies = [ [[package]] name = "perps-store" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -1951,7 +1964,7 @@ dependencies = [ [[package]] name = "perps-tui" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -1961,6 +1974,7 @@ dependencies = [ "perps-domain", "perps-funding", "ratatui", + "serde_json", "tokio", ] @@ -2060,7 +2074,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2081,7 +2095,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2754,7 +2768,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2839,7 +2853,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2878,7 +2892,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2904,7 +2918,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2998,13 +3012,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -3267,7 +3301,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] diff --git a/Cargo.toml b/Cargo.toml index 35ad035..5fc4680 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ sha3 = "0.10.8" rmp-serde = "1.3.0" tiny-keccak = { version = "2.0.2", features = ["keccak"] } log = "0.4" +bcs = "0.1.6" ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } starknet-core = "0.12.0" starknet-crypto = "0.7.3" diff --git a/README.md b/README.md index 64b9dae..5b5fee1 100644 --- a/README.md +++ b/README.md @@ -98,17 +98,7 @@ Global flags: `--json`, `--dry-run`, `--request-id`, `--strategy-id`, `--max-pos ## Architecture -``` -perps-cli ──┐ -perps-tui ──┼── perps-app ── perps-connector-api ── perps-connectors-* -perps-gateway ┘ │ - ├── perps-domain (canonical types + JSON contract envelope) - ├── perps-execution (order-intent state machine) - ├── perps-risk (pre-trade risk policy kernel) - ├── perps-funding (rate normalization + spread analytics) - ├── perps-market-graph (alias resolution + market identity) - └── perps-store (SQLite journal + migrations) -``` +![Architecture](img.png) 21 crates in the workspace. Three entry points (CLI, Gateway, TUI) share the same `perps-app` core. diff --git a/crates/perps-connectors-decibel/Cargo.toml b/crates/perps-connectors-decibel/Cargo.toml index 4588654..97f8f46 100644 --- a/crates/perps-connectors-decibel/Cargo.toml +++ b/crates/perps-connectors-decibel/Cargo.toml @@ -6,14 +6,17 @@ license.workspace = true [dependencies] async-stream.workspace = true +bcs.workspace = true async-trait.workspace = true chrono.workspace = true +ed25519-dalek.workspace = true futures.workspace = true hex.workspace = true perps-connector-api = { path = "../perps-connector-api" } perps-domain = { path = "../perps-domain" } reqwest.workspace = true serde.workspace = true +sha3.workspace = true serde_json.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true diff --git a/crates/perps-connectors-decibel/src/lib.rs b/crates/perps-connectors-decibel/src/lib.rs index 25d11a8..63662c9 100644 --- a/crates/perps-connectors-decibel/src/lib.rs +++ b/crates/perps-connectors-decibel/src/lib.rs @@ -6,12 +6,13 @@ use std::{ collections::HashMap, sync::Arc, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use async_stream::stream; use async_trait::async_trait; use chrono::{TimeZone, Utc}; +use ed25519_dalek::{Signer, SigningKey}; use futures::{SinkExt, StreamExt}; use perps_connector_api::{ ConnectorCapabilities, ConnectorError, ConnectorHealth, MarketDataEvent, MarketDataStream, @@ -24,11 +25,11 @@ use perps_domain::{ }; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; use serde::{Deserialize, Serialize}; +use sha3::{Digest, Sha3_256}; use tokio::sync::RwLock; use tokio_tungstenite::{connect_async, tungstenite}; use tracing::{debug, info, instrument, warn}; use url::Url; -use uuid::Uuid; // --------------------------------------------------------------------------- // Constants @@ -43,6 +44,18 @@ const VENUE_ID: &str = "decibel"; /// Prices and sizes are transmitted with 9 on-chain decimals for Move u64 encoding. const CHAIN_DECIMALS: u32 = 9; +/// Default Aptos fullnode REST API URL for transaction submission. +const DEFAULT_FULLNODE_URL: &str = "https://fullnode.mainnet.aptoslabs.com/v1"; + +/// Conservative gas limit for simulation before we know actual cost. +const DEFAULT_MAX_GAS: u64 = 200_000; + +/// Default gas unit price in octas (100 = 0.000001 APT). +const DEFAULT_GAS_UNIT_PRICE: u64 = 100; + +/// Safety margin multiplier for gas estimation (1.5x simulated). +const GAS_SAFETY_MARGIN: f64 = 1.5; + // --------------------------------------------------------------------------- // Decibel REST response models (serde) // --------------------------------------------------------------------------- @@ -168,6 +181,223 @@ struct WsMessage { payload: serde_json::Value, } +/// Payload components: (function_name, bcs_args, json_args). +type PayloadComponents = (String, Vec>, Vec); + +// --------------------------------------------------------------------------- +// BCS serialization helpers for Aptos transactions +// +// Aptos uses BCS (Binary Canonical Serialization) for transaction signing. +// We implement minimal BCS encoding for the types we need rather than +// pulling in the full Aptos SDK. +// --------------------------------------------------------------------------- + +/// Write a ULEB128-encoded u32 to a buffer. +fn bcs_write_uleb128(buf: &mut Vec, mut val: u32) { + loop { + let byte = (val & 0x7F) as u8; + val >>= 7; + if val == 0 { + buf.push(byte); + break; + } else { + buf.push(byte | 0x80); + } + } +} + +/// Write a BCS-encoded byte sequence (ULEB128 length prefix + raw bytes). +fn bcs_write_bytes(buf: &mut Vec, data: &[u8]) { + bcs_write_uleb128(buf, data.len() as u32); + buf.extend_from_slice(data); +} + +/// Write a BCS-encoded string (same as bytes: ULEB128 length + UTF-8). +fn bcs_write_str(buf: &mut Vec, s: &str) { + bcs_write_bytes(buf, s.as_bytes()); +} + +/// BCS-encode a single entry function argument: address (32 fixed bytes). +fn bcs_encode_address(addr: &[u8; 32]) -> Vec { + addr.to_vec() +} + +/// BCS-encode a single entry function argument: u64 (8 bytes LE). +fn bcs_encode_u64(v: u64) -> Vec { + v.to_le_bytes().to_vec() +} + +/// BCS-encode a single entry function argument: u128 (16 bytes LE). +fn bcs_encode_u128(v: u128) -> Vec { + v.to_le_bytes().to_vec() +} + +/// BCS-encode a single entry function argument: bool (1 byte). +fn bcs_encode_bool(v: bool) -> Vec { + vec![u8::from(v)] +} + +/// BCS-encode a single entry function argument: u8 (1 byte). +fn bcs_encode_u8(v: u8) -> Vec { + vec![v] +} + +/// BCS-encode Move `option::Option` (represented as `vector`). +/// None → empty vector, Some(v) → single-element vector. +fn bcs_encode_option_u64(v: Option) -> Vec { + match v { + None => vec![0], // ULEB128(0) = empty vector + Some(val) => { + let mut buf = vec![1]; // ULEB128(1) = one element + buf.extend_from_slice(&val.to_le_bytes()); + buf + } + } +} + +/// BCS-encode Move `option::Option
` (represented as `vector
`). +fn bcs_encode_option_address(v: Option<&[u8; 32]>) -> Vec { + match v { + None => vec![0], + Some(addr) => { + let mut buf = vec![1]; + buf.extend_from_slice(addr); + buf + } + } +} + +/// BCS-serialize an Aptos `RawTransaction` with an `EntryFunction` payload. +/// +/// Layout matches the Aptos SDK exactly: +/// - sender: AccountAddress (32 bytes, no prefix) +/// - sequence_number: u64 (8 bytes LE) +/// - payload: TransactionPayload::EntryFunction (variant index 2) +/// - module: ModuleId { address (32 bytes), name (BCS string) } +/// - function: Identifier (BCS string) +/// - ty_args: Vec (ULEB128 length, empty) +/// - args: Vec> (ULEB128 length, each element is BCS bytes) +/// - max_gas_amount: u64 +/// - gas_unit_price: u64 +/// - expiration_timestamp_secs: u64 +/// - chain_id: u8 +#[allow(clippy::too_many_arguments)] +fn bcs_serialize_raw_transaction( + sender: &[u8; 32], + sequence_number: u64, + module_address: &[u8; 32], + module_name: &str, + function_name: &str, + args: &[Vec], + max_gas_amount: u64, + gas_unit_price: u64, + expiration_timestamp_secs: u64, + chain_id: u8, +) -> Vec { + let mut buf = Vec::with_capacity(256); + + // sender: AccountAddress (fixed 32 bytes) + buf.extend_from_slice(sender); + + // sequence_number: u64 + buf.extend_from_slice(&sequence_number.to_le_bytes()); + + // payload: TransactionPayload::EntryFunction (variant 2) + bcs_write_uleb128(&mut buf, 2); + + // EntryFunction.module.address: AccountAddress (fixed 32 bytes) + buf.extend_from_slice(module_address); + // EntryFunction.module.name: Identifier (BCS string) + bcs_write_str(&mut buf, module_name); + + // EntryFunction.function: Identifier (BCS string) + bcs_write_str(&mut buf, function_name); + + // EntryFunction.ty_args: Vec (empty) + bcs_write_uleb128(&mut buf, 0); + + // EntryFunction.args: Vec> + bcs_write_uleb128(&mut buf, args.len() as u32); + for arg in args { + bcs_write_bytes(&mut buf, arg); + } + + // max_gas_amount: u64 + buf.extend_from_slice(&max_gas_amount.to_le_bytes()); + + // gas_unit_price: u64 + buf.extend_from_slice(&gas_unit_price.to_le_bytes()); + + // expiration_timestamp_secs: u64 + buf.extend_from_slice(&expiration_timestamp_secs.to_le_bytes()); + + // chain_id: u8 + buf.push(chain_id); + + buf +} + +/// Build a BCS-encoded `SignedTransaction` from raw transaction bytes and +/// an Ed25519 signature. +/// +/// Layout: +/// - raw_txn: [RawTransaction BCS bytes] +/// - authenticator: TransactionAuthenticator::Ed25519 (variant 0) +/// - public_key: Ed25519PublicKey (Vec of 32 bytes) +/// - signature: Ed25519Signature (Vec of 64 bytes) +fn bcs_build_signed_transaction( + raw_txn_bcs: &[u8], + public_key: &[u8], + signature: &[u8], +) -> Vec { + let mut buf = Vec::with_capacity(raw_txn_bcs.len() + 100); + buf.extend_from_slice(raw_txn_bcs); + + // TransactionAuthenticator::Ed25519 = variant 0 + bcs_write_uleb128(&mut buf, 0); + + // Ed25519PublicKey (BCS Vec: length-prefixed) + bcs_write_bytes(&mut buf, public_key); + + // Ed25519Signature (BCS Vec: length-prefixed) + bcs_write_bytes(&mut buf, signature); + + buf +} + +/// Parse a hex address string (with or without "0x" prefix) into a 32-byte +/// array, left-padding with zeros if shorter than 64 hex chars. +fn parse_aptos_address(hex_str: &str) -> Result<[u8; 32], ConnectorError> { + let cleaned = hex_str.strip_prefix("0x").unwrap_or(hex_str); + + if cleaned.len() > 64 { + return Err(ConnectorError::Invalid(format!( + "address too long: {hex_str}" + ))); + } + + // Left-pad with zeros to 64 hex characters + let padded = format!("{cleaned:0>64}"); + + let bytes = hex::decode(&padded) + .map_err(|e| ConnectorError::Invalid(format!("invalid hex address '{hex_str}': {e}")))?; + + let mut addr = [0u8; 32]; + addr.copy_from_slice(&bytes); + Ok(addr) +} + +/// Compute the Aptos signing prefix for a given struct name. +/// Returns SHA3-256(b"APTOS::{name}"). +fn aptos_signing_prefix(name: &str) -> [u8; 32] { + let mut hasher = Sha3_256::new(); + hasher.update(format!("APTOS::{name}").as_bytes()); + let result = hasher.finalize(); + let mut prefix = [0u8; 32]; + prefix.copy_from_slice(&result); + prefix +} + // --------------------------------------------------------------------------- // Cached market metadata // --------------------------------------------------------------------------- @@ -261,6 +491,14 @@ pub struct DecibelConnector { wallet_address: Option, private_key: Option, package_address: String, + /// Aptos fullnode REST API URL for transaction submission and simulation. + fullnode_url: String, + /// Separate HTTP client for fullnode calls (no Decibel-specific headers). + fullnode_http: reqwest::Client, + /// Aptos chain ID (1 = mainnet, 2 = testnet). + chain_id: u8, + /// Parsed Ed25519 signing key for transaction signing. + signing_key: Option, cache: Arc>, } @@ -302,6 +540,38 @@ impl DecibelConnector { .build() .expect("failed to build reqwest client"); + // Separate client for Aptos fullnode calls (no Decibel-specific headers). + let fullnode_http = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("failed to build fullnode reqwest client"); + + let fullnode_url = std::env::var("DECIBEL_FULLNODE_URL") + .unwrap_or_else(|_| DEFAULT_FULLNODE_URL.to_string()); + + // Parse Ed25519 signing key from hex private key. + let signing_key = private_key.as_deref().and_then(|pk_hex| { + let cleaned = pk_hex.strip_prefix("0x").unwrap_or(pk_hex); + match hex::decode(cleaned) { + Ok(key_bytes) if key_bytes.len() == 32 => { + let mut buf = [0u8; 32]; + buf.copy_from_slice(&key_bytes); + Some(SigningKey::from_bytes(&buf)) + } + Ok(key_bytes) => { + warn!( + len = key_bytes.len(), + "invalid Ed25519 private key length (expected 32 bytes), trading disabled" + ); + None + } + Err(e) => { + warn!(%e, "failed to decode Ed25519 private key hex, trading disabled"); + None + } + } + }); + Self { http, base_url: base_url.unwrap_or_else(|| DEFAULT_REST_URL.to_string()), @@ -310,6 +580,10 @@ impl DecibelConnector { wallet_address, private_key, package_address: DEFAULT_PACKAGE_ADDR.to_string(), + fullnode_url, + fullnode_http, + chain_id: 1, // mainnet + signing_key, cache: Arc::new(RwLock::new(MarketCache::default())), } } @@ -320,6 +594,18 @@ impl DecibelConnector { self } + /// Override the Aptos fullnode URL. + pub fn with_fullnode_url(mut self, url: impl Into) -> Self { + self.fullnode_url = url.into(); + self + } + + /// Override the Aptos chain ID (1 = mainnet, 2 = testnet). + pub fn with_chain_id(mut self, id: u8) -> Self { + self.chain_id = id; + self + } + // ----------------------------------------------------------------------- // REST helpers // ----------------------------------------------------------------------- @@ -539,7 +825,7 @@ impl DecibelConnector { .ok_or(ConnectorError::AuthRequired) } - /// Require a private key for trading endpoints. + /// Require a private key for trading endpoints (string check). fn require_private_key(&self) -> Result<&str, ConnectorError> { self.private_key.as_deref().ok_or_else(|| { ConnectorError::Unsupported( @@ -548,6 +834,21 @@ impl DecibelConnector { }) } + /// Require a parsed Ed25519 signing key for transaction signing. + fn require_signing_key(&self) -> Result<&SigningKey, ConnectorError> { + self.signing_key.as_ref().ok_or_else(|| { + ConnectorError::Unsupported( + "trading requires a valid Ed25519 private key for on-chain tx signing".to_string(), + ) + }) + } + + /// Get the sender address as a 32-byte array. + fn sender_address(&self) -> Result<[u8; 32], ConnectorError> { + let wallet = self.require_wallet()?; + parse_aptos_address(wallet) + } + // ----------------------------------------------------------------------- // REST data helpers // ----------------------------------------------------------------------- @@ -585,86 +886,455 @@ impl DecibelConnector { } // ----------------------------------------------------------------------- - // Order submission via Aptos REST (simulated transaction) - // - // Full on-chain order submission requires building an Aptos Move entry - // function call, signing it with Ed25519, and submitting via the Aptos - // fullnode API. This connector builds the BCS-serialized transaction - // payload and submits it. - // - // For a production deployment the private key should be stored in a - // secure enclave; this implementation signs in-process. + // Input validation (security-audit skill) // ----------------------------------------------------------------------- - /// Build a "place order" on-chain transaction payload and submit it via - /// the Aptos fullnode REST API, returning the tx hash. - #[instrument(skip(self, intent), fields(market = %intent.market_uid.key(), side = ?intent.side))] - async fn submit_onchain_order(&self, intent: &OrderIntent) -> Result { - let _private_key = self.require_private_key()?; + /// Validate an OrderIntent before submitting on-chain. + fn validate_order_intent(intent: &OrderIntent) -> Result<(), ConnectorError> { + if !intent.size.is_finite() || intent.size <= 0.0 { + return Err(ConnectorError::Invalid(format!( + "order size must be positive and finite, got {}", + intent.size + ))); + } + + if let Some(price) = intent.limit_price { + if !price.is_finite() || price <= 0.0 { + return Err(ConnectorError::Invalid(format!( + "limit price must be positive and finite, got {price}" + ))); + } + } + + if matches!(intent.order_type, OrderType::Limit) && intent.limit_price.is_none() { + return Err(ConnectorError::Invalid( + "limit orders require a limit_price".to_string(), + )); + } + + Ok(()) + } + + // ----------------------------------------------------------------------- + // Ed25519 transaction signing + // ----------------------------------------------------------------------- + + /// Sign a BCS-serialized RawTransaction with Ed25519. + /// + /// Returns `(signature_bytes, public_key_bytes)`. + fn sign_raw_transaction( + raw_txn_bcs: &[u8], + signing_key: &SigningKey, + ) -> ([u8; 64], [u8; 32]) { + let prefix = aptos_signing_prefix("RawTransaction"); + + let mut message = Vec::with_capacity(32 + raw_txn_bcs.len()); + message.extend_from_slice(&prefix); + message.extend_from_slice(raw_txn_bcs); + + let signature = signing_key.sign(&message); + let public_key = signing_key.verifying_key(); + + (signature.to_bytes(), public_key.to_bytes()) + } + + // ----------------------------------------------------------------------- + // Aptos fullnode RPC client + // ----------------------------------------------------------------------- + + /// Fetch the current sequence number for the sender account. + async fn get_sequence_number(&self, sender: &str) -> Result { + let url = format!( + "{}/accounts/{}", + self.fullnode_url.trim_end_matches('/'), + sender + ); + + let resp = self + .fullnode_http + .get(&url) + .send() + .await + .map_err(|e| ConnectorError::Unavailable(format!("fullnode request failed: {e}")))?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| ConnectorError::Internal(format!("fullnode response parse error: {e}")))?; + + if !status.is_success() { + return Err(map_aptos_error(&body, status.as_u16())); + } + + body["sequence_number"] + .as_str() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| { + ConnectorError::Internal(format!( + "missing sequence_number in account response: {body}" + )) + }) + } + + /// Build the JSON payload body used for simulation. + #[allow(clippy::too_many_arguments)] + fn build_json_payload( + &self, + sender: &str, + sequence_number: u64, + function: &str, + args: &[serde_json::Value], + max_gas_amount: u64, + gas_unit_price: u64, + expiration_secs: u64, + ) -> serde_json::Value { + serde_json::json!({ + "sender": sender, + "sequence_number": sequence_number.to_string(), + "max_gas_amount": max_gas_amount.to_string(), + "gas_unit_price": gas_unit_price.to_string(), + "expiration_timestamp_secs": expiration_secs.to_string(), + "payload": { + "type": "entry_function_payload", + "function": function, + "type_arguments": [], + "arguments": args + } + }) + } + + /// Simulate a transaction to estimate gas and catch errors early. + async fn simulate_transaction( + &self, + json_body: &serde_json::Value, + public_key_hex: &str, + ) -> Result { + let url = format!( + "{}/transactions/simulate", + self.fullnode_url.trim_end_matches('/') + ); + + let mut body = json_body.clone(); + body["signature"] = serde_json::json!({ + "type": "ed25519_signature", + "public_key": public_key_hex, + "signature": format!("0x{}", "0".repeat(128)) + }); + + let resp = self + .fullnode_http + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ConnectorError::Unavailable(format!("simulation request failed: {e}")))?; + + let status = resp.status(); + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| ConnectorError::Internal(format!("simulation parse error: {e}")))?; + + if !status.is_success() { + return Err(map_aptos_error(&result, status.as_u16())); + } + + let sim = if result.is_array() { + result.get(0).cloned().unwrap_or(result.clone()) + } else { + result + }; + + let success = sim["success"].as_bool().unwrap_or(false); + if !success { + let vm_status = sim["vm_status"].as_str().unwrap_or("unknown"); + return Err(map_move_abort_code(vm_status)); + } + + let gas_used = sim["gas_used"] + .as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_MAX_GAS); + + Ok(gas_used) + } + + /// Submit a BCS-encoded signed transaction to the fullnode. + async fn submit_signed_transaction( + &self, + signed_txn_bcs: &[u8], + ) -> Result { + let url = format!( + "{}/transactions", + self.fullnode_url.trim_end_matches('/') + ); + + let resp = self + .fullnode_http + .post(&url) + .header("Content-Type", "application/x.aptos.signed_transaction+bcs") + .body(signed_txn_bcs.to_vec()) + .send() + .await + .map_err(|e| ConnectorError::Unavailable(format!("submit request failed: {e}")))?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| ConnectorError::Internal(format!("submit response parse error: {e}")))?; + + if !status.is_success() { + return Err(map_aptos_error(&body, status.as_u16())); + } + + body["hash"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + ConnectorError::Internal(format!("missing hash in submit response: {body}")) + }) + } + + /// Poll for transaction confirmation (best-effort, 5s timeout). + async fn wait_for_transaction(&self, tx_hash: &str) -> bool { + let url = format!( + "{}/transactions/by_hash/{}", + self.fullnode_url.trim_end_matches('/'), + tx_hash + ); + + for _ in 0..10 { + tokio::time::sleep(Duration::from_millis(500)).await; + + let resp = match self.fullnode_http.get(&url).send().await { + Ok(r) => r, + Err(_) => continue, + }; + + if !resp.status().is_success() { + continue; + } + + if let Ok(body) = resp.json::().await { + if body["success"].as_bool() == Some(true) { + return true; + } + if body["success"].as_bool() == Some(false) { + return false; + } + } + } + + false + } + + // ----------------------------------------------------------------------- + // build_sign_submit orchestrator (gas-optimization skill) + // ----------------------------------------------------------------------- + + /// Full transaction lifecycle: build, simulate, sign, submit, poll. + #[instrument(skip(self, bcs_args, json_args), fields(function = %function))] + async fn build_sign_submit( + &self, + module_name: &str, + function_name: &str, + function: &str, + bcs_args: &[Vec], + json_args: &[serde_json::Value], + ) -> Result { + let signing_key = self.require_signing_key()?; let wallet = self.require_wallet()?; - let meta = self.resolve_market_meta(&intent.market_uid).await?; + let sender = self.sender_address()?; + let package_addr = parse_aptos_address(&self.package_address)?; + + let seq = self.get_sequence_number(wallet).await?; + + let expiry = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 30; + + let public_key_hex = format!("0x{}", hex::encode(signing_key.verifying_key().as_bytes())); + let sim_json = self.build_json_payload( + wallet, + seq, + function, + json_args, + DEFAULT_MAX_GAS, + DEFAULT_GAS_UNIT_PRICE, + expiry, + ); + + let gas_used = self + .simulate_transaction(&sim_json, &public_key_hex) + .await?; + + let max_gas = ((gas_used as f64) * GAS_SAFETY_MARGIN).ceil() as u64; + debug!(gas_used, max_gas, "simulation gas estimate"); + + let raw_txn_bcs = bcs_serialize_raw_transaction( + &sender, + seq, + &package_addr, + module_name, + function_name, + bcs_args, + max_gas, + DEFAULT_GAS_UNIT_PRICE, + expiry, + self.chain_id, + ); + + let (signature, public_key) = Self::sign_raw_transaction(&raw_txn_bcs, signing_key); + + let signed_txn = bcs_build_signed_transaction(&raw_txn_bcs, &public_key, &signature); + let tx_hash = self.submit_signed_transaction(&signed_txn).await?; + + info!(tx_hash = %tx_hash, "transaction submitted"); + + let confirmed = self.wait_for_transaction(&tx_hash).await; + if confirmed { + debug!(tx_hash = %tx_hash, "transaction confirmed on-chain"); + } else { + debug!(tx_hash = %tx_hash, "transaction poll timed out (may still land)"); + } + + Ok(tx_hash) + } + + // ----------------------------------------------------------------------- + // Entry function payload builders + // ----------------------------------------------------------------------- + + /// Build BCS and JSON args for `place_order_to_subaccount`. + fn build_place_order_payload( + &self, + wallet: &str, + meta: &MarketMeta, + intent: &OrderIntent, + ) -> Result { + let wallet_addr = parse_aptos_address(wallet)?; + let market_addr = parse_aptos_address(&meta.market_addr)?; - // Encode price & size to chain u64 (9 decimals) let chain_price = intent .limit_price .map(|p| float_to_chain_u64(p, CHAIN_DECIMALS)) + .transpose()? .unwrap_or(0u64); - let chain_size = float_to_chain_u64(intent.size, CHAIN_DECIMALS); + let chain_size = float_to_chain_u64(intent.size, CHAIN_DECIMALS)?; let is_buy = matches!(intent.side, OrderSide::Long); let tif: u8 = match intent.order_type { - OrderType::Market => 2, // IOC for market orders - OrderType::Limit => 0, // GTC + OrderType::Market => 2, + OrderType::Limit => 0, _ => 0, }; - // Build JSON payload for the Aptos REST submission API - let _payload = serde_json::json!({ - "function": format!("{}::dex_accounts_entry::place_order_to_subaccount", self.package_address), - "type_arguments": [], - "arguments": [ - wallet, - meta.market_addr, - chain_price.to_string(), - chain_size.to_string(), - is_buy, - tif, - intent.reduce_only, - serde_json::Value::Null, // client_order_id - serde_json::Value::Null, // stop_price - serde_json::Value::Null, // tp_trigger_price - serde_json::Value::Null, // tp_limit_price - serde_json::Value::Null, // sl_trigger_price - serde_json::Value::Null, // sl_limit_price - serde_json::Value::Null, // builder_addr - serde_json::Value::Null, // builder_fee_bps - ], - "type": "entry_function_payload" - }); + let function = format!( + "{}::dex_accounts_entry::place_order_to_subaccount", + self.package_address + ); + + let bcs_args = vec![ + bcs_encode_address(&wallet_addr), + bcs_encode_address(&market_addr), + bcs_encode_u64(chain_price), + bcs_encode_u64(chain_size), + bcs_encode_bool(is_buy), + bcs_encode_u8(tif), + bcs_encode_bool(intent.reduce_only), + bcs_encode_option_u64(None), + bcs_encode_option_u64(None), + bcs_encode_option_u64(None), + bcs_encode_option_u64(None), + bcs_encode_option_u64(None), + bcs_encode_option_u64(None), + bcs_encode_option_address(None), + bcs_encode_option_u64(None), + ]; + + let json_args: Vec = vec![ + serde_json::json!(wallet), + serde_json::json!(meta.market_addr), + serde_json::json!(chain_price.to_string()), + serde_json::json!(chain_size.to_string()), + serde_json::json!(is_buy), + serde_json::json!(tif), + serde_json::json!(intent.reduce_only), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + ]; + + Ok((function, bcs_args, json_args)) + } + + /// Build BCS and JSON args for `cancel_order_to_subaccount`. + fn build_cancel_order_payload( + &self, + wallet: &str, + market_addr_str: &str, + order_id: &str, + ) -> Result { + let wallet_addr = parse_aptos_address(wallet)?; + let market_addr = parse_aptos_address(market_addr_str)?; + let order_id_u128 = parse_order_id_u128(order_id)?; + + let function = format!( + "{}::dex_accounts_entry::cancel_order_to_subaccount", + self.package_address + ); + + let bcs_args = vec![ + bcs_encode_address(&wallet_addr), + bcs_encode_address(&market_addr), + bcs_encode_u128(order_id_u128), + ]; + + let json_args: Vec = vec![ + serde_json::json!(wallet), + serde_json::json!(market_addr_str), + serde_json::json!(order_id_u128.to_string()), + ]; + + Ok((function, bcs_args, json_args)) + } + + // ----------------------------------------------------------------------- + // On-chain order submission + // ----------------------------------------------------------------------- + + /// Build, sign, and submit a "place order" transaction. + #[instrument(skip(self, intent), fields(market = %intent.market_uid.key(), side = ?intent.side))] + async fn submit_onchain_order(&self, intent: &OrderIntent) -> Result { + Self::validate_order_intent(intent)?; + let wallet = self.require_wallet()?; + let meta = self.resolve_market_meta(&intent.market_uid).await?; + + let (function, bcs_args, json_args) = + self.build_place_order_payload(wallet, &meta, intent)?; info!( market = %meta.market_name, side = ?intent.side, - price = chain_price, - size = chain_size, - tif = tif, "submitting Decibel on-chain order" ); - // Submit to fullnode — this requires a signed transaction. - // Since we don't have a full BCS serializer in pure Rust without - // the Aptos SDK, we use the Aptos simulation + signing REST API. - // For now, we return the intent as Submitted and require an external - // Aptos signer daemon or the user to confirm. - // - // In production, integrate `aptos-sdk` crate or an external signer. - let tx_hash = format!("0x{}", hex::encode(Uuid::new_v4().as_bytes())); - - warn!( - tx_hash = %tx_hash, - "on-chain order submission stub — integrate aptos-sdk crate for real signing" - ); - - Ok(tx_hash) + self.build_sign_submit( + "dex_accounts_entry", + "place_order_to_subaccount", + &function, + &bcs_args, + &json_args, + ) + .await } /// Build and submit a cancel-order transaction. @@ -674,65 +1344,41 @@ impl DecibelConnector { order_id: &str, market_addr: &str, ) -> Result { - let _private_key = self.require_private_key()?; let wallet = self.require_wallet()?; + let (function, bcs_args, json_args) = + self.build_cancel_order_payload(wallet, market_addr, order_id)?; + info!( order_id = %order_id, market_addr = %market_addr, - wallet = %wallet, - package = %self.package_address, "submitting Decibel on-chain cancel" ); - let tx_hash = format!("0x{}", hex::encode(Uuid::new_v4().as_bytes())); - warn!( - tx_hash = %tx_hash, - "on-chain cancel submission stub — integrate aptos-sdk crate for real signing" - ); - - Ok(tx_hash) + self.build_sign_submit( + "dex_accounts_entry", + "cancel_order_to_subaccount", + &function, + &bcs_args, + &json_args, + ) + .await } - /// Build and submit an amend-order transaction. + /// Amend an order by cancelling and re-placing (no native amend). #[instrument(skip(self, new_intent), fields(order_id = %order_id))] async fn amend_onchain_order( &self, order_id: &str, new_intent: &OrderIntent, ) -> Result { - let _private_key = self.require_private_key()?; - let wallet = self.require_wallet()?; + Self::validate_order_intent(new_intent)?; let meta = self.resolve_market_meta(&new_intent.market_uid).await?; - let chain_price = new_intent - .limit_price - .map(|p| float_to_chain_u64(p, CHAIN_DECIMALS)) - .unwrap_or(0u64); - let chain_size = float_to_chain_u64(new_intent.size, CHAIN_DECIMALS); - let _is_buy = matches!(new_intent.side, OrderSide::Long); - let _tif: u8 = match new_intent.order_type { - OrderType::Market => 2, - OrderType::Limit => 0, - _ => 0, - }; - - info!( - order_id = %order_id, - market = %meta.market_name, - wallet = %wallet, - price = chain_price, - size = chain_size, - "submitting Decibel on-chain amend" - ); - - let tx_hash = format!("0x{}", hex::encode(Uuid::new_v4().as_bytes())); - warn!( - tx_hash = %tx_hash, - "on-chain amend submission stub — integrate aptos-sdk crate for real signing" - ); + self.cancel_onchain_order(order_id, &meta.market_addr) + .await?; - Ok(tx_hash) + self.submit_onchain_order(new_intent).await } } @@ -750,7 +1396,7 @@ impl PerpsConnector for DecibelConnector { ConnectorCapabilities { can_stream: true, can_read_account: self.wallet_address.is_some(), - can_trade: self.private_key.is_some() && self.wallet_address.is_some(), + can_trade: self.signing_key.is_some() && self.wallet_address.is_some(), can_reconcile: self.wallet_address.is_some(), supports_funding_history: true, } @@ -1562,7 +2208,392 @@ fn json_to_u32(v: &Option) -> u32 { /// Convert a floating-point value to a chain u64 with the given number of /// decimals (e.g. 9 for Move u64 encoding). -fn float_to_chain_u64(value: f64, decimals: u32) -> u64 { - let scale = 10u64.pow(decimals) as f64; - (value * scale).round() as u64 +/// +/// Returns an error for negative, NaN, infinity, or values that would +/// overflow u64 after scaling. +fn float_to_chain_u64(value: f64, decimals: u32) -> Result { + if value.is_nan() || value.is_infinite() { + return Err(ConnectorError::Invalid(format!( + "cannot convert {value} to chain u64: not finite" + ))); + } + if value < 0.0 { + return Err(ConnectorError::Invalid(format!( + "cannot convert negative value {value} to chain u64" + ))); + } + + let scale = 10u64.pow(decimals); + let max_input = u64::MAX as f64 / scale as f64; + if value > max_input { + return Err(ConnectorError::Invalid(format!( + "value {value} would overflow u64 with {decimals} decimals" + ))); + } + + Ok((value * scale as f64).round() as u64) +} + +/// Parse an order ID string to u128 (on-chain order IDs are u128). +/// Accepts decimal strings or hex strings with "0x" prefix. +fn parse_order_id_u128(order_id: &str) -> Result { + if let Some(hex_str) = order_id.strip_prefix("0x") { + u128::from_str_radix(hex_str, 16).map_err(|e| { + ConnectorError::Invalid(format!("invalid hex order_id '{order_id}': {e}")) + }) + } else { + order_id.parse::().map_err(|e| { + ConnectorError::Invalid(format!("invalid order_id '{order_id}': {e}")) + }) + } +} + +// --------------------------------------------------------------------------- +// Aptos error mapping (troubleshoot-errors skill) +// --------------------------------------------------------------------------- + +/// Map an Aptos fullnode REST API error response to a ConnectorError. +fn map_aptos_error(body: &serde_json::Value, status: u16) -> ConnectorError { + let error_code = body["error_code"].as_str().unwrap_or(""); + let message = body["message"].as_str().unwrap_or("unknown error"); + + match error_code { + "account_not_found" => ConnectorError::Invalid(format!( + "Aptos account not found: {message}" + )), + "resource_not_found" => ConnectorError::Invalid(format!( + "Aptos resource not found: {message}" + )), + "sequence_number_too_old" => ConnectorError::Invalid(format!( + "sequence number too old (concurrent tx?): {message}" + )), + "transaction_expired" => ConnectorError::Unavailable(format!( + "transaction expired before execution: {message}" + )), + "invalid_transaction_update" => ConnectorError::Invalid(format!( + "invalid transaction: {message}" + )), + "vm_error" => { + let vm_code = body["vm_error_code"].as_u64().unwrap_or(0); + ConnectorError::Internal(format!( + "Move VM error (code {vm_code}): {message}" + )) + } + _ => { + if status == 429 { + ConnectorError::RateLimited + } else if status == 401 || status == 403 { + ConnectorError::AuthRequired + } else { + ConnectorError::Unavailable(format!( + "Aptos fullnode error (HTTP {status}): {message}" + )) + } + } + } +} + +/// Map a Move abort VM status string to a ConnectorError. +fn map_move_abort_code(vm_status: &str) -> ConnectorError { + let abort_code = extract_abort_code(vm_status); + + match abort_code { + Some(1) => ConnectorError::Invalid( + "Move abort: insufficient margin or balance".to_string(), + ), + Some(2) => ConnectorError::Invalid( + "Move abort: invalid order parameters".to_string(), + ), + Some(3) => ConnectorError::Invalid( + "Move abort: order not found".to_string(), + ), + Some(4) => ConnectorError::Invalid( + "Move abort: market not active".to_string(), + ), + Some(5) => ConnectorError::Invalid( + "Move abort: position would exceed max leverage".to_string(), + ), + Some(code) => ConnectorError::Internal(format!( + "Move abort (code {code}): {vm_status}" + )), + None => ConnectorError::Internal(format!( + "transaction failed: {vm_status}" + )), + } +} + +/// Extract a numeric abort code from a VM status string. +/// +/// Aptos VM status strings look like: +/// - `"Move abort in 0x...::module: ECODE(0x1234)"` +/// - `"Move abort: 0x1234"` +fn extract_abort_code(vm_status: &str) -> Option { + // Try to find a hex code like 0x1234 + if let Some(idx) = vm_status.rfind("0x") { + let hex_str = &vm_status[idx + 2..]; + let hex_end = hex_str + .find(|c: char| !c.is_ascii_hexdigit()) + .unwrap_or(hex_str.len()); + if hex_end > 0 { + return u64::from_str_radix(&hex_str[..hex_end], 16).ok(); + } + } + + // Try to find a decimal number at the end + let digits: String = vm_status + .chars() + .rev() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .chars() + .rev() + .collect(); + + if !digits.is_empty() { + return digits.parse::().ok(); + } + + None +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_float_to_chain_u64_normal() { + assert_eq!(float_to_chain_u64(1.0, 9).unwrap(), 1_000_000_000); + assert_eq!(float_to_chain_u64(0.5, 9).unwrap(), 500_000_000); + assert_eq!(float_to_chain_u64(100.123, 9).unwrap(), 100_123_000_000); + assert_eq!(float_to_chain_u64(0.0, 9).unwrap(), 0); + } + + #[test] + fn test_float_to_chain_u64_overflow() { + assert!(float_to_chain_u64(f64::MAX, 9).is_err()); + assert!(float_to_chain_u64(1e19, 9).is_err()); + } + + #[test] + fn test_float_to_chain_u64_negative() { + assert!(float_to_chain_u64(-1.0, 9).is_err()); + assert!(float_to_chain_u64(-0.001, 9).is_err()); + } + + #[test] + fn test_float_to_chain_u64_nan_inf() { + assert!(float_to_chain_u64(f64::NAN, 9).is_err()); + assert!(float_to_chain_u64(f64::INFINITY, 9).is_err()); + assert!(float_to_chain_u64(f64::NEG_INFINITY, 9).is_err()); + } + + #[test] + fn test_parse_aptos_address_full() { + let addr = "0x50ead22afd6ffd9769e3b3d6e0e64a2a350d68e8b102c4e72e33d0b8cfdfdb06"; + let result = parse_aptos_address(addr).unwrap(); + assert_eq!(result[0], 0x50); + assert_eq!(result[31], 0x06); + } + + #[test] + fn test_parse_aptos_address_short() { + let addr = "0x1"; + let result = parse_aptos_address(addr).unwrap(); + assert_eq!(result[31], 1); + assert_eq!(result[0], 0); + } + + #[test] + fn test_parse_aptos_address_no_prefix() { + let addr = "0000000000000000000000000000000000000000000000000000000000000001"; + let result = parse_aptos_address(addr).unwrap(); + assert_eq!(result[31], 1); + } + + #[test] + fn test_parse_aptos_address_invalid() { + assert!(parse_aptos_address("0xZZZ").is_err()); + assert!(parse_aptos_address("0x").is_ok()); // empty = all zeros + } + + #[test] + fn test_parse_order_id_u128_decimal() { + assert_eq!(parse_order_id_u128("12345").unwrap(), 12345u128); + assert_eq!(parse_order_id_u128("0").unwrap(), 0u128); + } + + #[test] + fn test_parse_order_id_u128_hex() { + assert_eq!(parse_order_id_u128("0xff").unwrap(), 255u128); + assert_eq!(parse_order_id_u128("0x1a2b").unwrap(), 0x1a2bu128); + } + + #[test] + fn test_parse_order_id_u128_invalid() { + assert!(parse_order_id_u128("not_a_number").is_err()); + assert!(parse_order_id_u128("0xGGG").is_err()); + } + + fn make_test_intent( + order_type: OrderType, + size: f64, + limit_price: Option, + ) -> OrderIntent { + OrderIntent::new( + "decibel", + MarketUid::new("BTC", "USD", "USDC", ContractKind::PerpetualLinear), + OrderSide::Long, + order_type, + size, + limit_price, + false, + ) + } + + #[test] + fn test_validate_order_intent_valid() { + let intent = make_test_intent(OrderType::Limit, 1.0, Some(50000.0)); + assert!(DecibelConnector::validate_order_intent(&intent).is_ok()); + } + + #[test] + fn test_validate_order_intent_zero_size() { + let intent = make_test_intent(OrderType::Market, 0.0, None); + assert!(DecibelConnector::validate_order_intent(&intent).is_err()); + } + + #[test] + fn test_validate_order_intent_negative_size() { + let intent = make_test_intent(OrderType::Market, -1.0, None); + assert!(DecibelConnector::validate_order_intent(&intent).is_err()); + } + + #[test] + fn test_validate_order_intent_limit_without_price() { + let intent = make_test_intent(OrderType::Limit, 1.0, None); + assert!(DecibelConnector::validate_order_intent(&intent).is_err()); + } + + #[test] + fn test_bcs_serialize_deterministic() { + let sender = [1u8; 32]; + let module_addr = [2u8; 32]; + let args = vec![bcs_encode_u64(42), bcs_encode_bool(true)]; + + let bcs1 = bcs_serialize_raw_transaction( + &sender, 0, &module_addr, "module", "function", &args, + 100_000, 100, 1234567890, 1, + ); + let bcs2 = bcs_serialize_raw_transaction( + &sender, 0, &module_addr, "module", "function", &args, + 100_000, 100, 1234567890, 1, + ); + + assert_eq!(bcs1, bcs2); + assert!(!bcs1.is_empty()); + } + + #[test] + fn test_ed25519_sign_and_verify() { + use ed25519_dalek::Verifier; + + let signing_key = SigningKey::from_bytes(&[42u8; 32]); + let raw_txn_bcs = b"test transaction data"; + + let (sig_bytes, pub_bytes) = + DecibelConnector::sign_raw_transaction(raw_txn_bcs, &signing_key); + + // Verify the signature + let verifying_key = + ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes).unwrap(); + let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes); + + let prefix = aptos_signing_prefix("RawTransaction"); + let mut message = Vec::new(); + message.extend_from_slice(&prefix); + message.extend_from_slice(raw_txn_bcs); + + assert!(verifying_key.verify(&message, &signature).is_ok()); + } + + #[test] + fn test_extract_abort_code_hex() { + assert_eq!(extract_abort_code("Move abort: 0x1234"), Some(0x1234)); + assert_eq!( + extract_abort_code("Move abort in 0x1::module: ECODE(0xff)"), + Some(0xff) + ); + } + + #[test] + fn test_extract_abort_code_decimal() { + assert_eq!(extract_abort_code("abort code 42"), Some(42)); + } + + #[test] + fn test_extract_abort_code_none() { + assert_eq!(extract_abort_code("unknown error"), None); + } + + #[test] + fn test_bcs_uleb128_encoding() { + let mut buf = Vec::new(); + bcs_write_uleb128(&mut buf, 0); + assert_eq!(buf, vec![0]); + + buf.clear(); + bcs_write_uleb128(&mut buf, 127); + assert_eq!(buf, vec![127]); + + buf.clear(); + bcs_write_uleb128(&mut buf, 128); + assert_eq!(buf, vec![0x80, 0x01]); + + buf.clear(); + bcs_write_uleb128(&mut buf, 300); + assert_eq!(buf, vec![0xAC, 0x02]); + } + + #[test] + fn test_bcs_encode_option_u64() { + assert_eq!(bcs_encode_option_u64(None), vec![0]); + + let some = bcs_encode_option_u64(Some(42)); + assert_eq!(some[0], 1); // length 1 + assert_eq!(&some[1..], &42u64.to_le_bytes()); + } + + #[test] + fn test_map_aptos_error_variants() { + let body = serde_json::json!({ + "error_code": "account_not_found", + "message": "test" + }); + assert!(matches!( + map_aptos_error(&body, 404), + ConnectorError::Invalid(_) + )); + + let body = serde_json::json!({ + "error_code": "sequence_number_too_old", + "message": "test" + }); + assert!(matches!( + map_aptos_error(&body, 400), + ConnectorError::Invalid(_) + )); + + let body = serde_json::json!({ + "error_code": "", + "message": "rate limited" + }); + assert!(matches!( + map_aptos_error(&body, 429), + ConnectorError::RateLimited + )); + } } diff --git a/website/app/(marketing)/page.tsx b/website/app/(marketing)/page.tsx index 4d61a30..41ba4ae 100644 --- a/website/app/(marketing)/page.tsx +++ b/website/app/(marketing)/page.tsx @@ -5,9 +5,9 @@ import { CopyCommand } from "@/components/copy-command"; import { ThemeToggle } from "@/components/theme-toggle"; const repoUrl = "https://github.com/raintree-technology/perps"; -const npmUrl = "https://www.npmjs.com/package/@raintree-technology/perps"; +const cratesUrl = "https://crates.io/crates/perps"; -const installCommand = "npm i -g @raintree-technology/perps"; +const installCommand = "cargo install perps"; const textureChars = ".:-=+*#"; const texture = Array.from({ length: 140 }) @@ -96,8 +96,8 @@ export default function Home() {
- - npm package + + crates.io @@ -112,12 +112,341 @@ export default function Home() {
+ + {/* ── Features ──────────────────────────────────── */} +
+
+

Design principles

+
+
+

Contract-First

+

+ Every CLI response is a typed ContractEnvelope<T> — deterministic + JSON with ok/err discriminant, exit codes, and trace metadata. No surprises. +

+
+
+

Deterministic I/O

+

+ Same input always produces same output shape. Reads default to mainnet, + execution defaults to testnet. No hidden state, no ambient config. +

+
+
+

Idempotent Flows

+

+ Every order gets a client_order_id. Retries are safe. The SQLite journal + deduplicates and the execution engine reconciles against exchange state. +

+
+
+

Agent-First

+

+ Built for LLM agents and automation pipelines. Structured output, predictable + exit codes, no interactive prompts, no TUI dependencies for core paths. +

+
+
+
+
+ + {/* ── Architecture ──────────────────────────────── */} +
+
+

Architecture

+

Rust workspace, 21 crates

+

+ A modular Rust workspace where each crate owns a single responsibility. + The CLI binary orchestrates connectors, risk, execution, and storage through + clean trait boundaries. +

+
+
+

perps-cli

+

Main binary. Parses commands, bootstraps app context, dispatches to connectors.

+
+
+

perps-connector-api

+

The PerpsConnector trait — unified async interface for all exchange operations.

+
+
+

perps-execution

+

Order state machine. Manages intent → placed → filled lifecycle with idempotency.

+
+
+

perps-risk

+

5-rule policy engine evaluated before every order. CLI flags override defaults.

+
+
+

perps-store

+

SQLite journal at ~/.perps/journal.db. Always-on write-ahead logging for every event.

+
+
+

perps-domain

+

Shared types — MarketUid, OrderIntent, OrderRecord, ContractEnvelope, exit codes.

+
+
+

perps-app

+

Orchestration core. Bootstraps connectors, wires execution flow, and manages app context.

+
+
+

perps-funding

+

Funding rate normalization and cross-venue spread discovery.

+
+
+

perps-market-graph

+

Fuzzy market resolution. Maps user queries to canonical MarketUid across venues.

+
+
+

perps-gateway

+

HTTP REST API via Axum. Exposes CLI capabilities as a local agent gateway.

+
+
+

perps-tui

+

Terminal dashboard built with Ratatui. Real-time positions, orders, and market data.

+
+
+

perps-observability

+

Structured logging, metrics, and tracing instrumentation across all crates.

+
+
+
+
+ + {/* ── Exchanges ─────────────────────────────────── */} +
+
+
+

Supported exchanges

+

12 connectors, one interface

+

+ Each exchange implements the same PerpsConnector trait. Swap venue with a flag — + same commands, same output shape, same risk checks. +

+
+

DEX

+
+ Hyperliquid + dYdX v4 + Vertex + Aevo + Paradex + Orderly + GMX + Decibel +
+

CEX

+
+ Binance + Bybit + OKX +
+
+
+ + {/* ── Execution Flow ────────────────────────────── */} +
+
+

Execution flow

+

From command to confirmation

+

+ Every order follows the same deterministic pipeline — no shortcuts, no skipped steps. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepComponentDescription
1CLI parseValidate args, resolve market via fuzzy match, build OrderIntent
2Risk engineEvaluate 5 policy rules — reject if any threshold exceeded
3Journal writePersist intent to SQLite with client_order_id before sending
4Connector dispatchSend to exchange via PerpsConnector trait implementation
5Execution engineTrack state machine: intent → placed → partial → filled/cancelled
6ReconciliationVerify exchange state matches local journal, flag discrepancies
7Envelope responseReturn typed ContractEnvelope with data, exit code, and trace
+
+
+
+ + {/* ── Risk Engine ───────────────────────────────── */} +
+
+

Risk engine

+

5 rules, evaluated every order

+

+ Every order passes through the risk policy engine before reaching the exchange. + Rules are configured via CLI flags, env vars, or defaults — in that priority order. +

+
+
+

Confidence gate

+

Require a minimum confidence score on agent-generated orders. Default: 0.7

+
+
+

Daily loss limit

+

Cap realized + unrealized losses per rolling 24h window. Default: 2% of equity

+
+
+

Position notional

+

Max notional value for any single position. Default: 10% of account equity

+
+
+

Asset concentration

+

Max exposure to a single asset across all positions. Default: 25%

+
+
+

Total exposure

+

Aggregate gross notional across all positions. Default: 100% of equity

+
+
+
+
+ + {/* ── Funding Intelligence ──────────────────────── */} +
+
+

Funding intelligence

+

Normalized rates, cross-venue spreads

+

+ Funding rates vary wildly across exchanges — different intervals, different conventions. + The funding crate normalizes everything to 8h annualized rates and finds arbitrage spreads. +

+
+
+

Rate normalization

+

Convert 1h, 4h, 8h rates to a common 8h annualized basis for apples-to-apples comparison.

+
+
+

Spread discovery

+

Find the best long/short venue pairs for a given asset. Surface the widest funding spread automatically.

+
+
+

Historical tracking

+

Journal all funding snapshots to SQLite. Query historical rates for backtesting and trend analysis.

+
+
+
+
+ + {/* ── CLI Commands ──────────────────────────────── */} +
+
+

CLI commands

+

15 command namespaces

+

+ Every command outputs a typed ContractEnvelope. Pipe to jq, feed to agents, + or use the built-in TUI dashboard. +

+
+ perps universe + perps funding + perps market + perps account + perps order + perps arb + perps connector + perps risk + perps ops + perps gateway + perps journal + perps pair + perps portfolio + perps sim + perps tui +
+
+
+ + {/* ── Domain Types ──────────────────────────────── */} +
+
+

Domain types

+

Typed contracts, predictable output

+

+ Every CLI response is a ContractEnvelope<T> — a discriminated union + with structured data, exit codes, and trace metadata. +

+
+
+
ContractEnvelope — success
+
{`{
+  "ok": true,
+  "data": {
+    "symbol": "ETH-PERP",
+    "mark_price": "3245.50",
+    "funding_rate": "0.0001"
+  },
+  "exit_code": 0,
+  "trace_id": "a1b2c3d4"
+}`}
+
+
+
ContractEnvelope — error
+
{`{
+  "ok": false,
+  "error": {
+    "code": "RISK_BLOCKED",
+    "message": "daily loss limit exceeded",
+    "rule": "daily_loss"
+  },
+  "exit_code": 5,
+  "trace_id": "e5f6g7h8"
+}`}
+
+
+
+ MarketUid + OrderIntent + OrderRecord + PositionRecord + FundingSnapshot + BalanceSummary + TradeRecord + RiskResult +
+
+