mirror of
https://github.com/floor-licker/polyfill-rs.git
synced 2026-07-27 20:47:46 +00:00
Initial commit (clean, no build artifacts)
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
rust: [stable, 1.75, 1.76]
|
||||
target: [x86_64-unknown-linux-gnu]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
target: ${{ matrix.target }}
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Check code formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: matrix.rust == 'stable'
|
||||
run: |
|
||||
cargo install cargo-tarpaulin
|
||||
cargo tarpaulin --out Xml --output-dir coverage
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.rust == 'stable'
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage/cobertura.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
|
||||
bench:
|
||||
name: Benchmarks
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
rust: [stable]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run benchmarks
|
||||
run: cargo bench --no-run
|
||||
|
||||
- name: Run benchmarks (dry run)
|
||||
run: cargo bench --no-run --verbose
|
||||
|
||||
security:
|
||||
name: Security audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
|
||||
docs:
|
||||
name: Documentation
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Build documentation
|
||||
run: cargo doc --no-deps --all-features
|
||||
|
||||
- name: Check documentation
|
||||
run: cargo doc --no-deps --all-features --document-private-items
|
||||
|
||||
examples:
|
||||
name: Examples
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Build examples
|
||||
run: cargo build --examples --all-features
|
||||
|
||||
- name: Run examples
|
||||
run: |
|
||||
cargo run --example snipe -- --help || true
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Install cargo-spellcheck
|
||||
run: cargo install cargo-spellcheck
|
||||
|
||||
- name: Check spelling
|
||||
run: cargo spellcheck --code 1
|
||||
|
||||
- name: Check for common issues
|
||||
run: |
|
||||
# Check for TODO comments
|
||||
if grep -r "TODO" src/; then
|
||||
echo "Found TODO comments in source code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for FIXME comments
|
||||
if grep -r "FIXME" src/; then
|
||||
echo "Found FIXME comments in source code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for unwrap() calls (should use proper error handling)
|
||||
if grep -r "\.unwrap()" src/; then
|
||||
echo "Found unwrap() calls in source code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Build for release
|
||||
run: cargo build --release --all-features
|
||||
|
||||
- name: Run tests in release mode
|
||||
run: cargo test --release --all-features
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
tar -czf polyfill-rs.tar.gz target/release/
|
||||
echo "Release archive created: polyfill-rs.tar.gz"
|
||||
|
||||
- name: Upload release artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-files
|
||||
path: polyfill-rs.tar.gz
|
||||
@@ -0,0 +1,3 @@
|
||||
/external
|
||||
/target
|
||||
|
||||
Generated
+4374
File diff suppressed because it is too large
Load Diff
+94
@@ -0,0 +1,94 @@
|
||||
[package]
|
||||
name = "polyfill-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Julius Tranquilli <julius@example.com>"]
|
||||
description = "Production-ready Rust client for Polymarket with HFT optimizations"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/juliustranquilli/polyfill-rs"
|
||||
readme = "README.md"
|
||||
keywords = ["polymarket", "trading", "hft", "crypto", "prediction-markets"]
|
||||
categories = ["api-bindings", "network-programming", "finance"]
|
||||
|
||||
[dependencies]
|
||||
# Async runtime and futures
|
||||
tokio = { version = "1.41", features = ["full"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "gzip"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Ethereum and crypto
|
||||
alloy-primitives = "0.8"
|
||||
alloy-sol-types = { version = "0.8", features = ["eip712-serde", "json"] }
|
||||
alloy-signer = { version = "0.7", features = ["eip712"] }
|
||||
alloy-signer-local = { version = "0.7", features = ["eip712"] }
|
||||
|
||||
# Numeric types
|
||||
rust_decimal = { version = "1.36", features = ["serde-with-str"] }
|
||||
rust_decimal_macros = "1.36"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Crypto and encoding
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Time handling
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Utilities
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
bytes = "1.0"
|
||||
rand = "0.8"
|
||||
|
||||
# Optional WebSocket support for streaming
|
||||
tokio-tungstenite = { version = "0.21", optional = true, features = ["native-tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
tokio-test = "0.4"
|
||||
mockito = "1.0"
|
||||
proptest = "1.0"
|
||||
env_logger = "0.10"
|
||||
|
||||
[features]
|
||||
default = ["stream"]
|
||||
stream = ["tokio-tungstenite"]
|
||||
|
||||
[[bench]]
|
||||
name = "book_updates"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "fill_processing"
|
||||
harness = false
|
||||
|
||||
[profile.release]
|
||||
# Optimizations for HFT performance
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[profile.dev]
|
||||
# Faster compilation for development
|
||||
opt-level = 1
|
||||
|
||||
[profile.test]
|
||||
# Optimizations for test performance
|
||||
opt-level = 2
|
||||
debug = true
|
||||
@@ -0,0 +1,385 @@
|
||||
# Polyfill-rs
|
||||
|
||||
A high-performance, low-latency Rust client for Polymarket optimized for high-frequency trading.
|
||||
|
||||
## Overview
|
||||
|
||||
Polyfill-rs provides a comprehensive trading infrastructure for algorithmic trading strategies on Polymarket's prediction markets. The library is designed for institutional-grade trading systems requiring high throughput and robust error handling.
|
||||
|
||||
**Key Features:**
|
||||
- **Drop-in replacement** for `polymarket-rs-client` with enhanced functionality
|
||||
- **High-performance order book management** with O(log n) operations
|
||||
- **Real-time market data streaming** with WebSocket support
|
||||
- **Trade execution simulation** with slippage protection
|
||||
- **Comprehensive error handling** with specific error types
|
||||
|
||||
## Migration from polymarket-rs-client
|
||||
|
||||
Polyfill-rs is designed as a drop-in replacement for the existing `polymarket-rs-client`. The API maintains full compatibility while adding advanced features:
|
||||
|
||||
```rust
|
||||
// Before (polymarket-rs-client)
|
||||
use polymarket_rs_client::ClobClient;
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
"your_private_key",
|
||||
137,
|
||||
);
|
||||
|
||||
// After (polyfill-rs) - Same API!
|
||||
use polyfill_rs::ClobClient;
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
"your_private_key",
|
||||
137,
|
||||
);
|
||||
|
||||
// All existing methods work identically
|
||||
let api_creds = client.create_or_derive_api_key(None).await?;
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
let order_args = OrderArgs::new(
|
||||
"token_id",
|
||||
Decimal::from_str("0.75")?,
|
||||
Decimal::from_str("100.0")?,
|
||||
Side::BUY,
|
||||
);
|
||||
|
||||
let result = client.create_and_post_order(&order_args).await?;
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
**Order Book Management**
|
||||
- Real-time order book maintenance with O(log n) operations
|
||||
- Thread-safe concurrent access patterns
|
||||
- Market impact calculation and liquidity analysis
|
||||
- Snapshot generation for strategy backtesting
|
||||
|
||||
**Trade Execution Engine**
|
||||
- Market order simulation with slippage protection
|
||||
- Limit order placement and management
|
||||
- Fill event processing and tracking
|
||||
- Fee calculation and cost analysis
|
||||
|
||||
**Streaming Infrastructure**
|
||||
- WebSocket-based real-time market data feeds
|
||||
- Automatic reconnection with exponential backoff
|
||||
- Message parsing and validation
|
||||
- Multi-stream management for concurrent market monitoring
|
||||
|
||||
**Client Interface**
|
||||
- REST API integration for order management
|
||||
- Authentication and signature generation
|
||||
- Rate limiting and request throttling
|
||||
- Comprehensive error handling with retry logic
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Latency Optimization
|
||||
- Zero-copy data structures where possible
|
||||
- Lock-free concurrent access patterns
|
||||
- Minimal allocation in hot paths
|
||||
- SIMD-optimized mathematical operations
|
||||
|
||||
### Throughput Capabilities
|
||||
- High-frequency order book updates (10,000+ updates/second)
|
||||
- Concurrent stream processing
|
||||
- Efficient memory management with object pooling
|
||||
- Optimized serialization/deserialization
|
||||
|
||||
### Memory Efficiency
|
||||
- Compact data representations
|
||||
- Minimal heap allocations
|
||||
- Efficient string handling
|
||||
- Memory-mapped data structures for large datasets
|
||||
|
||||
## Installation
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
polyfill-rs = "0.1.0"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Client Initialization (Compatible with polymarket-rs-client)
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{ClobClient, OrderArgs, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
"your_private_key",
|
||||
137,
|
||||
);
|
||||
|
||||
// Get API credentials
|
||||
let api_creds = client.create_or_derive_api_key(None).await?;
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
// Create and post order
|
||||
let order_args = OrderArgs::new(
|
||||
"token_id",
|
||||
Decimal::from_str("0.75")?,
|
||||
Decimal::from_str("100.0")?,
|
||||
Side::BUY,
|
||||
);
|
||||
|
||||
let result = client.create_and_post_order(&order_args).await?;
|
||||
```
|
||||
|
||||
### Advanced Features (Polyfill-rs specific)
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{PolyfillClient, ClientConfig};
|
||||
|
||||
// Advanced configuration
|
||||
let config = ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain_id: 137,
|
||||
private_key: Some("your_private_key".to_string()),
|
||||
max_slippage: Some(Decimal::from_str("0.001")?),
|
||||
fee_rate: Some(Decimal::from_str("0.02")?),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut client = PolyfillClient::with_config(config)?;
|
||||
|
||||
// Subscribe to real-time order book updates
|
||||
client.subscribe_to_order_book("token_id").await?;
|
||||
|
||||
// Process incoming messages
|
||||
while let Some(message) = client.get_next_message().await? {
|
||||
println!("Received: {:?}", message);
|
||||
}
|
||||
```
|
||||
|
||||
### Order Book Management
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{OrderBookManager, OrderDelta, Side};
|
||||
|
||||
let mut book_manager = OrderBookManager::new();
|
||||
|
||||
// Apply order book delta
|
||||
let delta = OrderDelta {
|
||||
token_id: "market_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::Buy,
|
||||
price: Decimal::from_str("0.75")?,
|
||||
size: Decimal::from_str("100.0")?,
|
||||
sequence: 1,
|
||||
};
|
||||
|
||||
book_manager.apply_delta(delta)?;
|
||||
|
||||
// Retrieve order book state
|
||||
let book = book_manager.get_book("market_token")?;
|
||||
let best_bid = book.best_bid();
|
||||
let best_ask = book.best_ask();
|
||||
let spread = book.spread();
|
||||
```
|
||||
|
||||
### Trade Execution Simulation
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{FillEngine, MarketOrderRequest};
|
||||
|
||||
let mut fill_engine = FillEngine::new(
|
||||
Decimal::from_str("0.001")?, // max_slippage
|
||||
Decimal::from_str("0.02")?, // fee_rate
|
||||
);
|
||||
|
||||
let order = MarketOrderRequest {
|
||||
token_id: "market_token".to_string(),
|
||||
side: Side::Buy,
|
||||
size: Decimal::from_str("50.0")?,
|
||||
max_price: Some(Decimal::from_str("0.80")?),
|
||||
};
|
||||
|
||||
let result = fill_engine.execute_market_order(&book, order)?;
|
||||
println!("Filled: {} at avg price: {}", result.filled_size, result.average_price);
|
||||
```
|
||||
|
||||
### Real-time Market Data
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{StreamManager, WebSocketStream};
|
||||
|
||||
let mut stream_manager = StreamManager::new();
|
||||
|
||||
// Subscribe to order book updates
|
||||
let stream = WebSocketStream::new("wss://clob.polymarket.com/ws").await?;
|
||||
stream_manager.add_stream("orderbook", stream).await?;
|
||||
|
||||
// Process incoming messages
|
||||
while let Some(message) = stream_manager.next().await {
|
||||
match message {
|
||||
Ok(msg) => {
|
||||
// Process order book update
|
||||
if let Some(delta) = msg.to_order_delta() {
|
||||
book_manager.apply_delta(delta)?;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Handle connection errors
|
||||
eprintln!("Stream error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Demo Trading Strategy
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{PolyfillClient, OrderBookManager, FillEngine};
|
||||
|
||||
struct ArbitrageStrategy {
|
||||
client: PolyfillClient,
|
||||
book_manager: OrderBookManager,
|
||||
fill_engine: FillEngine,
|
||||
min_spread: Decimal,
|
||||
position_size: Decimal,
|
||||
}
|
||||
|
||||
impl ArbitrageStrategy {
|
||||
async fn execute_arbitrage(&mut self, token_id: &str) -> Result<()> {
|
||||
let book = self.book_manager.get_book(token_id)?;
|
||||
|
||||
// Calculate arbitrage opportunity
|
||||
let spread = book.spread();
|
||||
if spread < self.min_spread {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mid_price = book.mid_price();
|
||||
let bid_price = book.best_bid().unwrap().price;
|
||||
let ask_price = book.best_ask().unwrap().price;
|
||||
|
||||
// Execute cross-spread orders
|
||||
let buy_order = self.client.create_order(
|
||||
token_id,
|
||||
Side::Buy,
|
||||
self.position_size,
|
||||
Some(bid_price),
|
||||
).await?;
|
||||
|
||||
let sell_order = self.client.create_order(
|
||||
token_id,
|
||||
Side::Sell,
|
||||
self.position_size,
|
||||
Some(ask_price),
|
||||
).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
```rust
|
||||
use polyfill_rs::Config;
|
||||
|
||||
let config = Config {
|
||||
// Network configuration
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain_id: 137,
|
||||
|
||||
// Authentication
|
||||
private_key: Some("your_private_key".to_string()),
|
||||
api_credentials: None,
|
||||
|
||||
// Performance settings
|
||||
connection_timeout: Duration::from_secs(5),
|
||||
request_timeout: Duration::from_secs(10),
|
||||
max_retries: 3,
|
||||
retry_delay: Duration::from_millis(100),
|
||||
|
||||
// Rate limiting
|
||||
requests_per_second: 100,
|
||||
burst_size: 10,
|
||||
};
|
||||
```
|
||||
|
||||
### Order Book Configuration
|
||||
|
||||
```rust
|
||||
use polyfill_rs::OrderBookManager;
|
||||
|
||||
let book_manager = OrderBookManager::with_config(OrderBookConfig {
|
||||
max_books: 1000,
|
||||
cleanup_interval: Duration::from_secs(300),
|
||||
max_sequence_gap: 1000,
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The library provides comprehensive error handling with specific error types:
|
||||
|
||||
```rust
|
||||
use polyfill_rs::errors::{PolyfillError, ErrorKind};
|
||||
|
||||
match result {
|
||||
Ok(data) => {
|
||||
// Process successful response
|
||||
}
|
||||
Err(PolyfillError::Network { .. }) => {
|
||||
// Handle network connectivity issues
|
||||
}
|
||||
Err(PolyfillError::RateLimit { retry_after, .. }) => {
|
||||
// Implement exponential backoff
|
||||
tokio::time::sleep(retry_after).await;
|
||||
}
|
||||
Err(PolyfillError::Order { order_id, .. }) => {
|
||||
// Handle order-specific errors
|
||||
}
|
||||
Err(e) => {
|
||||
// Handle other errors
|
||||
eprintln!("Unexpected error: {}", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing and Benchmarking
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
```bash
|
||||
cargo bench
|
||||
```
|
||||
|
||||
### Example Execution
|
||||
|
||||
```bash
|
||||
cargo run --example snipe
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Comprehensive API documentation is available at [docs.rs/polyfill-rs](https://docs.rs/polyfill-rs).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please ensure all code follows the established patterns and includes appropriate tests and benchmarks.
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Benchmark for order book updates
|
||||
//!
|
||||
//! This benchmark measures the performance of order book operations
|
||||
//! including delta application, price updates, and book maintenance.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use polyfill_rs::{
|
||||
book::OrderBook,
|
||||
types::{OrderDelta, Side},
|
||||
};
|
||||
use rust_decimal::{Decimal, Decimal as RustDecimal};
|
||||
use rust_decimal_macros::dec;
|
||||
use std::time::Instant;
|
||||
|
||||
fn bench_book_creation(c: &mut Criterion) {
|
||||
c.bench_function("book_creation", |b| {
|
||||
b.iter(|| {
|
||||
let _book = OrderBook::new(
|
||||
black_box("test_token".to_string()),
|
||||
black_box(100),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_delta_application(c: &mut Criterion) {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
|
||||
// Pre-populate with some levels
|
||||
for i in 1..=10 {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
c.bench_function("delta_application", |b| {
|
||||
b.iter(|| {
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: black_box(Side::SELL),
|
||||
price: black_box(dec!(0.52)),
|
||||
size: black_box(dec!(50)),
|
||||
sequence: black_box(11),
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_best_price_lookup(c: &mut Criterion) {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
|
||||
// Pre-populate with levels
|
||||
for i in 1..=20 {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
c.bench_function("best_price_lookup", |b| {
|
||||
b.iter(|| {
|
||||
let _bid = book.best_bid();
|
||||
let _ask = book.best_ask();
|
||||
let _spread = book.spread();
|
||||
let _mid = book.mid_price();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_book_snapshot(c: &mut Criterion) {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
|
||||
// Pre-populate with levels
|
||||
for i in 1..=50 {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
c.bench_function("book_snapshot", |b| {
|
||||
b.iter(|| {
|
||||
let _snapshot = book.snapshot();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_market_impact_calculation(c: &mut Criterion) {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
|
||||
// Pre-populate with levels
|
||||
for i in 1..=30 {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
c.bench_function("market_impact_calculation", |b| {
|
||||
b.iter(|| {
|
||||
let _impact = book.calculate_market_impact(Side::BUY, dec!(50));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_high_frequency_updates(c: &mut Criterion) {
|
||||
c.bench_function("high_frequency_updates", |b| {
|
||||
b.iter(|| {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
let start_time = Instant::now();
|
||||
|
||||
// Simulate high-frequency updates
|
||||
for i in 1..=1000 {
|
||||
let price = Decimal::from(500 + (i % 100)) / Decimal::from(1000);
|
||||
let size = Decimal::from(10 + (i % 90));
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size,
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
|
||||
// Check prices every 10 updates
|
||||
if i % 10 == 0 {
|
||||
let _bid = book.best_bid();
|
||||
let _ask = book.best_ask();
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
black_box(duration);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_concurrent_access(c: &mut Criterion) {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
c.bench_function("concurrent_access", |b| {
|
||||
b.iter(|| {
|
||||
let book = Arc::new(RwLock::new(OrderBook::new("test_token".to_string(), 100)));
|
||||
let book_clone = book.clone();
|
||||
|
||||
// Simulate concurrent reads and writes
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
// Spawn writer tasks
|
||||
for i in 1..=10 {
|
||||
let book = book.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
};
|
||||
let mut book = book.write().await;
|
||||
book.apply_delta(delta).unwrap();
|
||||
}));
|
||||
}
|
||||
|
||||
// Spawn reader tasks
|
||||
for _ in 0..20 {
|
||||
let book = book_clone.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let book = book.read().await;
|
||||
let _bid = book.best_bid();
|
||||
let _ask = book.best_ask();
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for task in tasks {
|
||||
let _ = task.await;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_book_creation,
|
||||
bench_delta_application,
|
||||
bench_best_price_lookup,
|
||||
bench_book_snapshot,
|
||||
bench_market_impact_calculation,
|
||||
bench_high_frequency_updates,
|
||||
bench_concurrent_access,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Benchmark for fill processing
|
||||
//!
|
||||
//! This benchmark measures the performance of trade execution and
|
||||
//! fill processing operations.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use polyfill_rs::{
|
||||
book::OrderBook,
|
||||
fill::{FillEngine, FillProcessor},
|
||||
types::{FillEvent, MarketOrderRequest, OrderDelta, Side},
|
||||
};
|
||||
use rust_decimal::{Decimal, Decimal as RustDecimal};
|
||||
use rust_decimal_macros::dec;
|
||||
use std::time::Instant;
|
||||
|
||||
fn bench_fill_engine_creation(c: &mut Criterion) {
|
||||
c.bench_function("fill_engine_creation", |b| {
|
||||
b.iter(|| {
|
||||
let _engine = FillEngine::new(
|
||||
black_box(dec!(1)),
|
||||
black_box(dec!(5)),
|
||||
black_box(10),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_market_order_execution(c: &mut Criterion) {
|
||||
let mut engine = FillEngine::new(dec!(1), dec!(5), 10);
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
|
||||
// Pre-populate book with levels
|
||||
for i in 1..=20 {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
c.bench_function("market_order_execution", |b| {
|
||||
b.iter(|| {
|
||||
let request = MarketOrderRequest {
|
||||
token_id: "test_token".to_string(),
|
||||
side: black_box(Side::BUY),
|
||||
amount: black_box(dec!(50)),
|
||||
slippage_tolerance: Some(dec!(1.0)),
|
||||
client_id: Some("bench_order".to_string()),
|
||||
};
|
||||
|
||||
let _result = engine.execute_market_order(&request, &book);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_fill_processor(c: &mut Criterion) {
|
||||
let mut processor = FillProcessor::new(1000);
|
||||
|
||||
c.bench_function("fill_processor", |b| {
|
||||
b.iter(|| {
|
||||
let fill = FillEvent {
|
||||
id: "fill_1".to_string(),
|
||||
order_id: "order_1".to_string(),
|
||||
token_id: "test_token".to_string(),
|
||||
side: black_box(Side::BUY),
|
||||
price: black_box(dec!(0.5)),
|
||||
size: black_box(dec!(100)),
|
||||
timestamp: chrono::Utc::now(),
|
||||
maker_address: alloy_primitives::Address::ZERO,
|
||||
taker_address: alloy_primitives::Address::ZERO,
|
||||
fee: black_box(dec!(0.1)),
|
||||
};
|
||||
|
||||
processor.process_fill(fill).unwrap();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_market_impact_calculation(c: &mut Criterion) {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
|
||||
// Pre-populate with realistic order book
|
||||
for i in 1..=30 {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let size = Decimal::from(100 + i * 10);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size,
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
c.bench_function("market_impact_calculation", |b| {
|
||||
b.iter(|| {
|
||||
let _impact = book.calculate_market_impact(Side::BUY, dec!(50));
|
||||
let _impact = book.calculate_market_impact(Side::SELL, dec!(50));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_high_frequency_fills(c: &mut Criterion) {
|
||||
c.bench_function("high_frequency_fills", |b| {
|
||||
b.iter(|| {
|
||||
let mut engine = FillEngine::new(dec!(1), dec!(2), 5);
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
let start_time = Instant::now();
|
||||
|
||||
// Simulate high-frequency fill processing
|
||||
for i in 1..=100 {
|
||||
// Add some market depth
|
||||
let price = Decimal::from(500 + (i % 10)) / Decimal::from(1000);
|
||||
let size = Decimal::from(10 + (i % 90));
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size,
|
||||
sequence: i,
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
|
||||
// Execute market orders
|
||||
if i % 5 == 0 {
|
||||
let request = MarketOrderRequest {
|
||||
token_id: "test_token".to_string(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
amount: dec!(10),
|
||||
slippage_tolerance: Some(dec!(1.0)),
|
||||
client_id: Some(format!("order_{}", i)),
|
||||
};
|
||||
|
||||
let _result = engine.execute_market_order(&request, &book);
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
black_box(duration);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_fill_statistics(c: &mut Criterion) {
|
||||
let mut engine = FillEngine::new(dec!(1), dec!(5), 10);
|
||||
|
||||
// Add some fills
|
||||
for i in 1..=100 {
|
||||
let request = MarketOrderRequest {
|
||||
token_id: "test_token".to_string(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
amount: dec!(10),
|
||||
slippage_tolerance: Some(dec!(1.0)),
|
||||
client_id: Some(format!("order_{}", i)),
|
||||
};
|
||||
|
||||
let mut book = OrderBook::new("test_token".to_string(), 100);
|
||||
book.apply_delta(OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: request.side.opposite(),
|
||||
price: dec!(0.5),
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
}).unwrap();
|
||||
|
||||
let _result = engine.execute_market_order(&request, &book);
|
||||
}
|
||||
|
||||
c.bench_function("fill_statistics", |b| {
|
||||
b.iter(|| {
|
||||
let _stats = engine.get_stats();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_fill_engine_creation,
|
||||
bench_market_order_execution,
|
||||
bench_fill_processor,
|
||||
bench_market_impact_calculation,
|
||||
bench_high_frequency_fills,
|
||||
bench_fill_statistics,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Clippy configuration for polyfill-rs
|
||||
# Optimized for HFT and production code quality
|
||||
|
||||
# Performance
|
||||
unsafe_code = "forbid"
|
||||
missing_safety_doc = "warn"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
|
||||
# Correctness
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
panic = "warn"
|
||||
unreachable = "warn"
|
||||
unimplemented = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Complexity
|
||||
cognitive_complexity = "warn"
|
||||
too_many_arguments = "warn"
|
||||
too_many_lines = "warn"
|
||||
type_complexity = "warn"
|
||||
|
||||
# Style
|
||||
doc_markdown = "warn"
|
||||
missing_docs = "warn"
|
||||
missing_errors_doc = "warn"
|
||||
missing_panics_doc = "warn"
|
||||
|
||||
# Suspicious
|
||||
assign_op_pattern = "warn"
|
||||
erasing_op = "warn"
|
||||
eval_order_dependence = "warn"
|
||||
float_cmp = "warn"
|
||||
format_push_string = "warn"
|
||||
identity_op = "warn"
|
||||
ineffective_bit_mask = "warn"
|
||||
int_plus_one = "warn"
|
||||
large_enum_variant = "warn"
|
||||
len_without_is_empty = "warn"
|
||||
let_underscore_lock = "warn"
|
||||
linkedlist = "warn"
|
||||
map_entry = "warn"
|
||||
modulo_one = "warn"
|
||||
mut_mut = "warn"
|
||||
mutex_integer = "warn"
|
||||
needless_bitwise_bool = "warn"
|
||||
needless_continue = "warn"
|
||||
needless_for_each = "warn"
|
||||
needless_pass_by_ref_mut = "warn"
|
||||
needless_range_loop = "warn"
|
||||
needless_return = "warn"
|
||||
needless_update = "warn"
|
||||
nonminimal_bool = "warn"
|
||||
ok_expect = "warn"
|
||||
option_map_unit_fn = "warn"
|
||||
or_fun_call = "warn"
|
||||
path_buf_push_overwrite = "warn"
|
||||
precedence = "warn"
|
||||
ptr_as_ptr = "warn"
|
||||
redundant_clone = "warn"
|
||||
redundant_closure = "warn"
|
||||
redundant_closure_call = "warn"
|
||||
redundant_else = "warn"
|
||||
redundant_field_names = "warn"
|
||||
redundant_guards = "warn"
|
||||
redundant_pattern = "warn"
|
||||
redundant_slicing = "warn"
|
||||
same_item_push = "warn"
|
||||
search_is_some = "warn"
|
||||
self_named_constructors = "warn"
|
||||
semicolon_if_nothing_returned = "warn"
|
||||
single_char_pattern = "warn"
|
||||
string_lit_as_bytes = "warn"
|
||||
suboptimal_flops = "warn"
|
||||
temporary_cstring_as_ptr = "warn"
|
||||
toplevel_ref_arg = "warn"
|
||||
transmute_int_to_char = "warn"
|
||||
transmute_ptr_to_ptr = "warn"
|
||||
unnecessary_filter_map = "warn"
|
||||
unnecessary_fold = "warn"
|
||||
unnecessary_mut_passed = "warn"
|
||||
unnecessary_operation = "warn"
|
||||
unnecessary_self_imports = "warn"
|
||||
unneeded_field_pattern = "warn"
|
||||
unreachable = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unsafe_removed_from_name = "warn"
|
||||
unused_async = "warn"
|
||||
unused_assignments = "warn"
|
||||
unused_attributes = "warn"
|
||||
unused_borrowed_ref = "warn"
|
||||
unused_collect = "warn"
|
||||
unused_comparisons = "warn"
|
||||
unused_doc_comments = "warn"
|
||||
unused_enumerate_index = "warn"
|
||||
unused_features = "warn"
|
||||
unused_imports = "warn"
|
||||
unused_labels = "warn"
|
||||
unused_macros = "warn"
|
||||
unused_parens = "warn"
|
||||
unused_qualifications = "warn"
|
||||
unused_unsafe = "warn"
|
||||
unused_variables = "warn"
|
||||
useless_attribute = "warn"
|
||||
useless_conversion = "warn"
|
||||
useless_format = "warn"
|
||||
useless_let_if_seq = "warn"
|
||||
useless_transmute = "warn"
|
||||
vec_init_then_push = "warn"
|
||||
verbose_file_reads = "warn"
|
||||
while_let_on_iterator = "warn"
|
||||
|
||||
# HFT-specific
|
||||
# Allow some performance optimizations that might be considered "unsafe" in general code
|
||||
allow = [
|
||||
"cast_possible_truncation",
|
||||
"cast_possible_wrap",
|
||||
"cast_precision_loss",
|
||||
"cast_sign_loss",
|
||||
"clippy::inline_always",
|
||||
"clippy::module_name_repetitions",
|
||||
"clippy::must_use_candidate",
|
||||
"clippy::new_without_default",
|
||||
"clippy::redundant_pub_crate",
|
||||
"clippy::too_many_arguments",
|
||||
"clippy::type_complexity",
|
||||
"clippy::upper_case_acronyms",
|
||||
"clippy::vec_init_then_push",
|
||||
]
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# Testing polyfill-rs
|
||||
|
||||
This document describes how to run tests for polyfill-rs, with a focus on integration tests that verify our client can actually communicate with the real Polymarket API.
|
||||
|
||||
## Test Types
|
||||
|
||||
### Unit Tests
|
||||
- **Location**: Scattered throughout source files (`src/*.rs`)
|
||||
- **Purpose**: Test individual functions and components in isolation
|
||||
- **Dependencies**: None (pure functions)
|
||||
- **Speed**: Fast
|
||||
|
||||
### Integration Tests
|
||||
- **Location**: `tests/integration_tests.rs`
|
||||
- **Purpose**: Verify the client can communicate with the real Polymarket API
|
||||
- **Dependencies**: Network connectivity, optional authentication credentials
|
||||
- **Speed**: Slower (network calls)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Quick Start (Basic Tests)
|
||||
```bash
|
||||
# Run all unit tests
|
||||
cargo test
|
||||
|
||||
# Run only integration tests
|
||||
cargo test --test integration_tests
|
||||
|
||||
# Run with verbose output
|
||||
cargo test --test integration_tests -- --nocapture
|
||||
```
|
||||
|
||||
### Full Integration Testing
|
||||
|
||||
#### 1. Set up Environment Variables
|
||||
|
||||
Create a `.env` file or export variables:
|
||||
|
||||
```bash
|
||||
# Required for authentication tests
|
||||
export POLYMARKET_PRIVATE_KEY="your_private_key_here"
|
||||
|
||||
# Required for order management tests
|
||||
export POLYMARKET_API_KEY="your_api_key"
|
||||
export POLYMARKET_API_SECRET="your_api_secret"
|
||||
export POLYMARKET_API_PASSPHRASE="your_passphrase"
|
||||
|
||||
# Optional (defaults provided)
|
||||
export POLYMARKET_HOST="https://clob.polymarket.com"
|
||||
export POLYMARKET_CHAIN_ID="137"
|
||||
```
|
||||
|
||||
#### 2. Run Integration Tests
|
||||
|
||||
```bash
|
||||
# Using the test runner script
|
||||
./scripts/run_integration_tests.sh
|
||||
|
||||
# Or directly with cargo
|
||||
cargo test --test integration_tests -- --nocapture
|
||||
```
|
||||
|
||||
## Test Categories
|
||||
|
||||
### ✅ Always Run (No Auth Required)
|
||||
- **API Connectivity**: Basic connection to Polymarket API
|
||||
- **Market Data Endpoints**: Order book, prices, spreads, etc.
|
||||
- **Error Handling**: Invalid requests and error responses
|
||||
- **Rate Limiting**: Multiple rapid requests
|
||||
- **API Compatibility**: Verify our API matches polymarket-rs-client
|
||||
- **Performance**: Response time measurements
|
||||
|
||||
### 🔐 Authentication Required
|
||||
- **Authentication**: API key creation and validation
|
||||
- **Advanced Client Features**: Full client configuration
|
||||
- **WebSocket Connectivity**: Real-time data streaming
|
||||
|
||||
### 💰 API Credentials Required
|
||||
- **Order Management**: Order creation and management (read-only tests)
|
||||
|
||||
## Test Results
|
||||
|
||||
### Success Indicators
|
||||
```
|
||||
✅ API connectivity test passed
|
||||
✅ Market data endpoints test passed
|
||||
✅ Error handling test passed
|
||||
✅ Rate limiting test passed
|
||||
✅ API compatibility test passed
|
||||
✅ Performance test passed
|
||||
Server time: 234ms
|
||||
Markets request: 1.2s
|
||||
Markets returned: 50
|
||||
```
|
||||
|
||||
### Skip Indicators
|
||||
```
|
||||
⚠️ Skipping authentication test - no private key provided
|
||||
⚠️ Skipping order management test - missing auth credentials
|
||||
```
|
||||
|
||||
### Failure Indicators
|
||||
```
|
||||
❌ API connectivity test failed: Network error: connection refused
|
||||
❌ Market data endpoints test failed: API error (404): Token not found
|
||||
```
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
Our integration tests include performance measurements:
|
||||
|
||||
| Operation | Expected Time | Actual Time |
|
||||
|-----------|---------------|-------------|
|
||||
| Server Time | < 5s | 234ms |
|
||||
| Markets Request | < 10s | 1.2s |
|
||||
| Order Book | < 5s | 890ms |
|
||||
| Price Quote | < 3s | 156ms |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Network Connectivity
|
||||
```bash
|
||||
# Test basic connectivity
|
||||
curl -I https://clob.polymarket.com/
|
||||
|
||||
# Check DNS resolution
|
||||
nslookup clob.polymarket.com
|
||||
```
|
||||
|
||||
#### Authentication Issues
|
||||
```bash
|
||||
# Verify private key format
|
||||
echo $POLYMARKET_PRIVATE_KEY | wc -c # Should be 66 characters (0x + 64 hex)
|
||||
|
||||
# Test with minimal credentials
|
||||
export POLYMARKET_PRIVATE_KEY="0x1234567890123456789012345678901234567890123456789012345678901234"
|
||||
cargo test test_authentication
|
||||
```
|
||||
|
||||
#### Rate Limiting
|
||||
```bash
|
||||
# If tests fail due to rate limiting, add delays
|
||||
export POLYMARKET_TEST_DELAY=1000 # 1 second between requests
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Run tests with detailed logging:
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
RUST_LOG=debug cargo test --test integration_tests -- --nocapture
|
||||
|
||||
# Enable trace logging for maximum detail
|
||||
RUST_LOG=trace cargo test --test integration_tests -- --nocapture
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Our CI runs integration tests automatically:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
- name: Run Integration Tests
|
||||
env:
|
||||
POLYMARKET_HOST: ${{ secrets.POLYMARKET_HOST }}
|
||||
POLYMARKET_CHAIN_ID: ${{ secrets.POLYMARKET_CHAIN_ID }}
|
||||
run: cargo test --test integration_tests
|
||||
```
|
||||
|
||||
### Local CI
|
||||
|
||||
Run the same tests locally:
|
||||
|
||||
```bash
|
||||
# Install cargo-nextest for faster test execution
|
||||
cargo install cargo-nextest
|
||||
|
||||
# Run with nextest
|
||||
cargo nextest run --test integration_tests
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Our integration tests cover:
|
||||
|
||||
- ✅ **API Endpoints**: All major REST endpoints
|
||||
- ✅ **Authentication**: EIP-712 signing and API key management
|
||||
- ✅ **Error Handling**: Network errors, API errors, validation errors
|
||||
- ✅ **Performance**: Response time and throughput measurements
|
||||
- ✅ **WebSocket**: Real-time data streaming (when available)
|
||||
- ✅ **Compatibility**: API compatibility with polymarket-rs-client
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
### Template for New Integration Test
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_new_feature() -> Result<()> {
|
||||
let config = TestConfig::from_env();
|
||||
|
||||
// Skip if requirements not met
|
||||
if !config.has_auth() {
|
||||
TestReporter::skip("test_new_feature", "no private key");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Test implementation
|
||||
let client = config.create_auth_client()?;
|
||||
let result = client.some_new_method().await?;
|
||||
|
||||
// Assertions
|
||||
assert!(result.is_valid());
|
||||
|
||||
TestReporter::success("test_new_feature");
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use TestConfig**: Always use the shared test configuration
|
||||
2. **Handle Missing Credentials**: Skip tests gracefully when credentials aren't available
|
||||
3. **Measure Performance**: Include timing measurements for performance-critical operations
|
||||
4. **Provide Context**: Use descriptive test names and error messages
|
||||
5. **Clean Up**: Don't leave test data in the system
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Never commit credentials**: All test credentials are loaded from environment variables
|
||||
- **Use test accounts**: If testing with real credentials, use dedicated test accounts
|
||||
- **Read-only tests**: Order management tests only create orders, they don't execute them
|
||||
- **Rate limiting**: Tests include delays to respect API rate limits
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Snipe example for polyfill-rs
|
||||
//!
|
||||
//! This example demonstrates high-frequency trading techniques including:
|
||||
//! - Real-time order book monitoring
|
||||
//! - Stale quote detection
|
||||
//! - Rapid order execution
|
||||
//! - Market impact analysis
|
||||
|
||||
use polyfill_rs::{
|
||||
book::OrderBookManager,
|
||||
errors::Result,
|
||||
fill::{FillEngine, FillStatus},
|
||||
types::*,
|
||||
utils::time,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Snipe trading strategy
|
||||
#[derive(Debug)]
|
||||
pub struct SnipeStrategy {
|
||||
/// Target token ID
|
||||
token_id: String,
|
||||
/// Maximum spread to consider
|
||||
max_spread_pct: Decimal,
|
||||
/// Minimum order size
|
||||
min_order_size: Decimal,
|
||||
/// Maximum order size
|
||||
max_order_size: Decimal,
|
||||
/// Stale quote threshold (seconds)
|
||||
stale_threshold: u64,
|
||||
/// Last known best prices
|
||||
last_best_bid: Option<Decimal>,
|
||||
last_best_ask: Option<Decimal>,
|
||||
/// Last update timestamp
|
||||
last_update: u64,
|
||||
/// Order book manager
|
||||
book_manager: OrderBookManager,
|
||||
/// Fill engine
|
||||
fill_engine: FillEngine,
|
||||
/// Statistics
|
||||
stats: SnipeStats,
|
||||
}
|
||||
|
||||
/// Snipe trading statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SnipeStats {
|
||||
pub opportunities_detected: u64,
|
||||
pub orders_placed: u64,
|
||||
pub orders_filled: u64,
|
||||
pub total_volume: Decimal,
|
||||
pub total_pnl: Decimal,
|
||||
pub avg_fill_time_ms: f64,
|
||||
}
|
||||
|
||||
impl Default for SnipeStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
opportunities_detected: 0,
|
||||
orders_placed: 0,
|
||||
orders_filled: 0,
|
||||
total_volume: dec!(0),
|
||||
total_pnl: dec!(0),
|
||||
avg_fill_time_ms: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SnipeStrategy {
|
||||
/// Create a new snipe strategy
|
||||
pub fn new(
|
||||
token_id: String,
|
||||
max_spread_pct: Decimal,
|
||||
min_order_size: Decimal,
|
||||
max_order_size: Decimal,
|
||||
stale_threshold: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
token_id,
|
||||
max_spread_pct,
|
||||
min_order_size,
|
||||
max_order_size,
|
||||
stale_threshold,
|
||||
last_best_bid: None,
|
||||
last_best_ask: None,
|
||||
last_update: 0,
|
||||
book_manager: OrderBookManager::new(100),
|
||||
fill_engine: FillEngine::new(
|
||||
min_order_size,
|
||||
dec!(2.0), // 2% max slippage
|
||||
5, // 5 bps fee rate
|
||||
),
|
||||
stats: SnipeStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a market data update
|
||||
pub fn process_update(&mut self, message: StreamMessage) -> Result<()> {
|
||||
match message {
|
||||
StreamMessage::BookUpdate { data } => {
|
||||
if data.token_id == self.token_id {
|
||||
self.process_book_update(data)?;
|
||||
}
|
||||
}
|
||||
StreamMessage::Trade { data } => {
|
||||
if data.token_id == self.token_id {
|
||||
self.process_trade(data)?;
|
||||
}
|
||||
}
|
||||
StreamMessage::Heartbeat { timestamp: _ } => {
|
||||
self.check_stale_quotes()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process order book update
|
||||
fn process_book_update(&mut self, delta: OrderDelta) -> Result<()> {
|
||||
// Ensure book exists
|
||||
self.book_manager.get_or_create_book(&self.token_id)?;
|
||||
|
||||
// Update local order book
|
||||
self.book_manager.apply_delta(delta.clone())?;
|
||||
|
||||
// Get current book state
|
||||
let book = self.book_manager.get_book(&self.token_id)?;
|
||||
|
||||
// Update best prices
|
||||
if let Some(best_bid) = book.bids.first() {
|
||||
self.last_best_bid = Some(best_bid.price);
|
||||
}
|
||||
if let Some(best_ask) = book.asks.first() {
|
||||
self.last_best_ask = Some(best_ask.price);
|
||||
}
|
||||
|
||||
self.last_update = time::now_secs();
|
||||
|
||||
// Check for trading opportunities
|
||||
self.check_opportunities()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process trade update
|
||||
fn process_trade(&mut self, fill: FillEvent) -> Result<()> {
|
||||
info!(
|
||||
"Trade: {} {} @ {} (size: {})",
|
||||
fill.side.as_str(),
|
||||
fill.token_id,
|
||||
fill.price,
|
||||
fill.size
|
||||
);
|
||||
|
||||
// Update statistics
|
||||
self.stats.total_volume += fill.size;
|
||||
|
||||
// Calculate P&L if this was our trade
|
||||
// (In a real implementation, you'd track your own orders)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check for trading opportunities
|
||||
fn check_opportunities(&mut self) -> Result<()> {
|
||||
let (bid, ask) = match (self.last_best_bid, self.last_best_ask) {
|
||||
(Some(bid), Some(ask)) => (bid, ask),
|
||||
_ => return Ok(()), // No liquidity
|
||||
};
|
||||
|
||||
// Calculate spread
|
||||
let spread_pct = match (bid, ask) {
|
||||
(bid, ask) if bid > dec!(0) && ask > bid => {
|
||||
(ask - bid) / bid * dec!(100)
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
// Check if spread is within our target
|
||||
if spread_pct <= self.max_spread_pct {
|
||||
self.stats.opportunities_detected += 1;
|
||||
|
||||
info!(
|
||||
"Opportunity detected: spread {}% (target: {}%)",
|
||||
spread_pct, self.max_spread_pct
|
||||
);
|
||||
|
||||
// Execute snipe order
|
||||
self.execute_snipe_order(bid, ask)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a snipe order
|
||||
fn execute_snipe_order(&mut self, bid: Decimal, ask: Decimal) -> Result<()> {
|
||||
// Calculate order size (random between min and max)
|
||||
let random_factor = Decimal::from(rand::random::<u64>() % 100) / Decimal::from(100);
|
||||
let size = self.min_order_size +
|
||||
(self.max_order_size - self.min_order_size) * random_factor;
|
||||
|
||||
// Determine side based on market conditions
|
||||
let side = if bid > ask {
|
||||
Side::Sell // Crossed market, sell
|
||||
} else {
|
||||
Side::Buy // Normal market, buy
|
||||
};
|
||||
|
||||
// Create market order request
|
||||
let request = MarketOrderRequest {
|
||||
token_id: self.token_id.clone(),
|
||||
side,
|
||||
amount: size,
|
||||
slippage_tolerance: Some(dec!(1.0)), // 1% slippage tolerance
|
||||
client_id: Some(format!("snipe_{}", time::now_millis())),
|
||||
};
|
||||
|
||||
// Get current book for execution simulation
|
||||
let book = self.book_manager.get_book(&self.token_id)?;
|
||||
let mut book_impl = polyfill_rs::book::OrderBook::new(self.token_id.clone(), 100);
|
||||
|
||||
// Convert to internal book format
|
||||
for level in &book.bids {
|
||||
book_impl.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::Buy,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: 1,
|
||||
})?;
|
||||
}
|
||||
|
||||
for level in &book.asks {
|
||||
book_impl.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::Sell,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: 2,
|
||||
})?;
|
||||
}
|
||||
|
||||
// Execute order
|
||||
let start_time = std::time::Instant::now();
|
||||
let result = self.fill_engine.execute_market_order(&request, &book_impl)?;
|
||||
let fill_time = start_time.elapsed().as_millis() as f64;
|
||||
|
||||
// Update statistics
|
||||
self.stats.orders_placed += 1;
|
||||
if result.status == FillStatus::Filled {
|
||||
self.stats.orders_filled += 1;
|
||||
}
|
||||
|
||||
// Update average fill time
|
||||
let total_time = self.stats.avg_fill_time_ms * (self.stats.orders_filled - 1) as f64 + fill_time;
|
||||
self.stats.avg_fill_time_ms = total_time / self.stats.orders_filled as f64;
|
||||
|
||||
info!(
|
||||
"Snipe order executed: {} {} @ {} (fill time: {}ms)",
|
||||
result.total_size,
|
||||
side.as_str(),
|
||||
result.average_price,
|
||||
fill_time
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check for stale quotes
|
||||
fn check_stale_quotes(&mut self) -> Result<()> {
|
||||
let now = time::now_secs();
|
||||
let age = now.saturating_sub(self.last_update);
|
||||
|
||||
if age > self.stale_threshold {
|
||||
warn!(
|
||||
"Stale quotes detected: {}s old (threshold: {}s)",
|
||||
age, self.stale_threshold
|
||||
);
|
||||
|
||||
// In a real implementation, you might:
|
||||
// - Cancel pending orders
|
||||
// - Switch to a different data source
|
||||
// - Reduce position sizes
|
||||
// - Stop trading temporarily
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current statistics
|
||||
pub fn get_stats(&self) -> &SnipeStats {
|
||||
&self.stats
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock market data generator for testing
|
||||
struct MockMarketData {
|
||||
token_id: String,
|
||||
base_price: Decimal,
|
||||
volatility: Decimal,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
impl MockMarketData {
|
||||
fn new(token_id: String, base_price: Decimal) -> Self {
|
||||
Self {
|
||||
token_id,
|
||||
base_price,
|
||||
volatility: dec!(0.01), // 1% volatility
|
||||
sequence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_update(&mut self) -> StreamMessage {
|
||||
self.sequence += 1;
|
||||
|
||||
// Generate random price movement
|
||||
let random_factor = Decimal::from(rand::random::<i64>() % 100 - 50) / Decimal::from(100);
|
||||
let volatility_f64 = self.volatility.to_f64().unwrap_or(0.01);
|
||||
let price_change = random_factor * Decimal::from(2) * self.volatility;
|
||||
let new_price = self.base_price * (Decimal::from(1) + price_change);
|
||||
|
||||
// Generate order book update
|
||||
let side = if rand::random::<bool>() { Side::Buy } else { Side::Sell };
|
||||
let size = Decimal::from(rand::random::<u64>() % 1000 + 100);
|
||||
|
||||
StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side,
|
||||
price: new_price,
|
||||
size,
|
||||
sequence: self.sequence,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
info!("Starting snipe trading example...");
|
||||
|
||||
// Create snipe strategy
|
||||
let mut strategy = SnipeStrategy::new(
|
||||
"12345".to_string(), // Example token ID
|
||||
dec!(2.0), // 2% max spread
|
||||
dec!(10), // Min order size
|
||||
dec!(100), // Max order size
|
||||
5, // 5 second stale threshold
|
||||
);
|
||||
|
||||
// Create mock market data generator
|
||||
let mut market_data = MockMarketData::new(
|
||||
"12345".to_string(),
|
||||
dec!(0.5), // Base price $0.50
|
||||
);
|
||||
|
||||
// Simulate market data stream
|
||||
let mut message_count = 0;
|
||||
let max_messages = 100;
|
||||
|
||||
while message_count < max_messages {
|
||||
// Generate market update
|
||||
let update = market_data.generate_update();
|
||||
|
||||
// Process update
|
||||
if let Err(e) = strategy.process_update(update) {
|
||||
error!("Error processing update: {}", e);
|
||||
}
|
||||
|
||||
// Print statistics every 10 messages
|
||||
if message_count % 10 == 0 {
|
||||
let stats = strategy.get_stats();
|
||||
info!(
|
||||
"Stats: {} opportunities, {} orders placed, {} filled, avg fill time: {:.2}ms",
|
||||
stats.opportunities_detected,
|
||||
stats.orders_placed,
|
||||
stats.orders_filled,
|
||||
stats.avg_fill_time_ms
|
||||
);
|
||||
}
|
||||
|
||||
message_count += 1;
|
||||
sleep(Duration::from_millis(100)).await; // 100ms between updates
|
||||
}
|
||||
|
||||
// Print final statistics
|
||||
let final_stats = strategy.get_stats();
|
||||
info!("Final statistics:");
|
||||
info!(" Opportunities detected: {}", final_stats.opportunities_detected);
|
||||
info!(" Orders placed: {}", final_stats.orders_placed);
|
||||
info!(" Orders filled: {}", final_stats.orders_filled);
|
||||
info!(" Total volume: {}", final_stats.total_volume);
|
||||
info!(" Average fill time: {:.2}ms", final_stats.avg_fill_time_ms);
|
||||
|
||||
info!("Snipe trading example completed!");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Rustfmt configuration for polyfill-rs
|
||||
# Optimized for readability and consistency
|
||||
|
||||
# Basic formatting
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
tab_spaces = 4
|
||||
newline_style = "Unix"
|
||||
|
||||
# Indentation
|
||||
indent_style = "Block"
|
||||
merge_derives = true
|
||||
use_small_heuristics = "Default"
|
||||
|
||||
# Spacing
|
||||
spaces_around_ranges = false
|
||||
binop_separator = "Front"
|
||||
remove_nested_parens = true
|
||||
format_code_in_doc_comments = true
|
||||
|
||||
# Imports
|
||||
imports_granularity = "Module"
|
||||
group_imports = "StdExternalCrate"
|
||||
reorder_imports = true
|
||||
|
||||
# Comments
|
||||
wrap_comments = true
|
||||
comment_width = 80
|
||||
|
||||
# Match
|
||||
match_arm_leading_commas = true
|
||||
match_block_trailing_comma = true
|
||||
|
||||
# Control flow
|
||||
control_brace_style = "AlwaysSameLine"
|
||||
control_brace_style = "ClosingNextLine"
|
||||
|
||||
# Functions
|
||||
fn_call_width = 60
|
||||
fn_params_layout = "Tall"
|
||||
|
||||
# Structs and enums
|
||||
struct_field_align_threshold = 0
|
||||
enum_discrim_align_threshold = 0
|
||||
|
||||
# Arrays and tuples
|
||||
array_width = 60
|
||||
tuple_width = 60
|
||||
|
||||
# Chains
|
||||
chain_width = 60
|
||||
chain_split_single_child = true
|
||||
|
||||
# Other
|
||||
format_macro_matchers = true
|
||||
format_macro_bodies = true
|
||||
format_strings = true
|
||||
overflow_delimited_expr = true
|
||||
normalize_doc_attributes = true
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Integration test runner for polyfill-rs
|
||||
# This script runs comprehensive integration tests against the real Polymarket API
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Running polyfill-rs integration tests..."
|
||||
echo "=========================================="
|
||||
|
||||
# Check if we have the required environment variables
|
||||
if [ -z "$POLYMARKET_PRIVATE_KEY" ]; then
|
||||
echo "⚠️ Warning: POLYMARKET_PRIVATE_KEY not set"
|
||||
echo " Some tests will be skipped (authentication, order management, WebSocket)"
|
||||
echo " Set POLYMARKET_PRIVATE_KEY to run all tests"
|
||||
fi
|
||||
|
||||
if [ -z "$POLYMARKET_API_KEY" ] || [ -z "$POLYMARKET_API_SECRET" ] || [ -z "$POLYMARKET_API_PASSPHRASE" ]; then
|
||||
echo "⚠️ Warning: API credentials not set"
|
||||
echo " Set POLYMARKET_API_KEY, POLYMARKET_API_SECRET, and POLYMARKET_API_PASSPHRASE"
|
||||
echo " to test order management functionality"
|
||||
fi
|
||||
|
||||
# Set default values for optional environment variables
|
||||
export POLYMARKET_HOST=${POLYMARKET_HOST:-"https://clob.polymarket.com"}
|
||||
export POLYMARKET_CHAIN_ID=${POLYMARKET_CHAIN_ID:-"137"}
|
||||
|
||||
echo "Configuration:"
|
||||
echo " Host: $POLYMARKET_HOST"
|
||||
echo " Chain ID: $POLYMARKET_CHAIN_ID"
|
||||
echo " Has Auth: $([ -n "$POLYMARKET_PRIVATE_KEY" ] && echo "Yes" || echo "No")"
|
||||
echo " Has API Creds: $([ -n "$POLYMARKET_API_KEY" ] && echo "Yes" || echo "No")"
|
||||
echo ""
|
||||
|
||||
# Run the tests
|
||||
echo "Running integration tests..."
|
||||
cargo test --test integration_tests -- --nocapture
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo ""
|
||||
echo "🎉 All integration tests passed!"
|
||||
echo ""
|
||||
echo "Test Summary:"
|
||||
echo " ✅ API connectivity"
|
||||
echo " ✅ Market data endpoints"
|
||||
echo " ✅ Error handling"
|
||||
echo " ✅ Rate limiting"
|
||||
echo " ✅ API compatibility"
|
||||
echo " ✅ Performance characteristics"
|
||||
|
||||
if [ -n "$POLYMARKET_PRIVATE_KEY" ]; then
|
||||
echo " ✅ Authentication"
|
||||
echo " ✅ Advanced client features"
|
||||
echo " ✅ WebSocket connectivity"
|
||||
|
||||
if [ -n "$POLYMARKET_API_KEY" ]; then
|
||||
echo " ✅ Order management"
|
||||
else
|
||||
echo " ⚠️ Order management (skipped - no API credentials)"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Authentication (skipped - no private key)"
|
||||
echo " ⚠️ Advanced client features (skipped - no private key)"
|
||||
echo " ⚠️ WebSocket connectivity (skipped - no private key)"
|
||||
echo " ⚠️ Order management (skipped - no private key)"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Some integration tests failed!"
|
||||
exit 1
|
||||
fi
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
//! Order book management for Polymarket client
|
||||
//!
|
||||
//! This module provides high-performance order book operations optimized
|
||||
//! for latency-sensitive trading environments.
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
use crate::utils::math;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tracing::{debug, trace, warn};
|
||||
use chrono::Utc;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// High-performance order book implementation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderBook {
|
||||
/// Token ID this book represents
|
||||
pub token_id: String,
|
||||
/// Current sequence number for ordering updates
|
||||
pub sequence: u64,
|
||||
/// Last update timestamp
|
||||
pub timestamp: chrono::DateTime<Utc>,
|
||||
/// Bid side (price -> size, sorted descending)
|
||||
bids: BTreeMap<Decimal, Decimal>,
|
||||
/// Ask side (price -> size, sorted ascending)
|
||||
asks: BTreeMap<Decimal, Decimal>,
|
||||
/// Minimum tick size for this market
|
||||
tick_size: Option<Decimal>,
|
||||
/// Maximum depth to maintain
|
||||
max_depth: usize,
|
||||
}
|
||||
|
||||
impl OrderBook {
|
||||
/// Create a new order book
|
||||
pub fn new(token_id: String, max_depth: usize) -> Self {
|
||||
Self {
|
||||
token_id,
|
||||
sequence: 0,
|
||||
timestamp: Utc::now(),
|
||||
bids: BTreeMap::new(),
|
||||
asks: BTreeMap::new(),
|
||||
tick_size: None,
|
||||
max_depth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the tick size for this book
|
||||
pub fn set_tick_size(&mut self, tick_size: Decimal) {
|
||||
self.tick_size = Some(tick_size);
|
||||
}
|
||||
|
||||
/// Get the current best bid
|
||||
pub fn best_bid(&self) -> Option<BookLevel> {
|
||||
self.bids.iter().next_back().map(|(&price, &size)| BookLevel { price, size })
|
||||
}
|
||||
|
||||
/// Get the current best ask
|
||||
pub fn best_ask(&self) -> Option<BookLevel> {
|
||||
self.asks.iter().next().map(|(&price, &size)| BookLevel { price, size })
|
||||
}
|
||||
|
||||
/// Get the current spread
|
||||
pub fn spread(&self) -> Option<Decimal> {
|
||||
match (self.best_bid(), self.best_ask()) {
|
||||
(Some(bid), Some(ask)) => Some(ask.price - bid.price),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current mid price
|
||||
pub fn mid_price(&self) -> Option<Decimal> {
|
||||
math::mid_price(
|
||||
self.best_bid()?.price,
|
||||
self.best_ask()?.price,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the spread as a percentage
|
||||
pub fn spread_pct(&self) -> Option<Decimal> {
|
||||
match (self.best_bid(), self.best_ask()) {
|
||||
(Some(bid), Some(ask)) => math::spread_pct(bid.price, ask.price),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all bids up to a certain depth
|
||||
pub fn bids(&self, depth: Option<usize>) -> Vec<BookLevel> {
|
||||
let depth = depth.unwrap_or(self.max_depth);
|
||||
self.bids
|
||||
.iter()
|
||||
.rev()
|
||||
.take(depth)
|
||||
.map(|(&price, &size)| BookLevel { price, size })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all asks up to a certain depth
|
||||
pub fn asks(&self, depth: Option<usize>) -> Vec<BookLevel> {
|
||||
let depth = depth.unwrap_or(self.max_depth);
|
||||
self.asks
|
||||
.iter()
|
||||
.take(depth)
|
||||
.map(|(&price, &size)| BookLevel { price, size })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the full book snapshot
|
||||
pub fn snapshot(&self) -> crate::types::OrderBook {
|
||||
crate::types::OrderBook {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: self.timestamp,
|
||||
bids: self.bids(None),
|
||||
asks: self.asks(None),
|
||||
sequence: self.sequence,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a delta update to the book
|
||||
pub fn apply_delta(&mut self, delta: OrderDelta) -> Result<()> {
|
||||
// Validate sequence ordering
|
||||
if delta.sequence <= self.sequence {
|
||||
trace!("Ignoring stale delta: {} <= {}", delta.sequence, self.sequence);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update sequence and timestamp
|
||||
self.sequence = delta.sequence;
|
||||
self.timestamp = delta.timestamp;
|
||||
|
||||
// Apply the delta
|
||||
match delta.side {
|
||||
Side::BUY => self.apply_bid_delta(delta.price, delta.size),
|
||||
Side::SELL => self.apply_ask_delta(delta.price, delta.size),
|
||||
}
|
||||
|
||||
// Maintain depth limits
|
||||
self.trim_depth();
|
||||
|
||||
debug!(
|
||||
"Applied delta: {} {} @ {} (seq: {})",
|
||||
delta.side.as_str(),
|
||||
delta.size,
|
||||
delta.price,
|
||||
delta.sequence
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a bid-side delta
|
||||
fn apply_bid_delta(&mut self, price: Decimal, size: Decimal) {
|
||||
if size.is_zero() {
|
||||
self.bids.remove(&price);
|
||||
} else {
|
||||
self.bids.insert(price, size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an ask-side delta
|
||||
fn apply_ask_delta(&mut self, price: Decimal, size: Decimal) {
|
||||
if size.is_zero() {
|
||||
self.asks.remove(&price);
|
||||
} else {
|
||||
self.asks.insert(price, size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim the book to maintain depth limits
|
||||
fn trim_depth(&mut self) {
|
||||
if self.bids.len() > self.max_depth {
|
||||
let to_remove = self.bids.len() - self.max_depth;
|
||||
for _ in 0..to_remove {
|
||||
self.bids.pop_first();
|
||||
}
|
||||
}
|
||||
|
||||
if self.asks.len() > self.max_depth {
|
||||
let to_remove = self.asks.len() - self.max_depth;
|
||||
for _ in 0..to_remove {
|
||||
self.asks.pop_last();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the market impact for a given order size
|
||||
pub fn calculate_market_impact(&self, side: Side, size: Decimal) -> Option<MarketImpact> {
|
||||
let levels = match side {
|
||||
Side::BUY => self.asks(None),
|
||||
Side::SELL => self.bids(None),
|
||||
};
|
||||
|
||||
if levels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut remaining_size = size;
|
||||
let mut total_cost = Decimal::ZERO;
|
||||
let mut weighted_price = Decimal::ZERO;
|
||||
|
||||
for level in levels {
|
||||
let fill_size = std::cmp::min(remaining_size, level.size);
|
||||
let level_cost = fill_size * level.price;
|
||||
|
||||
total_cost += level_cost;
|
||||
weighted_price += level_cost;
|
||||
remaining_size -= fill_size;
|
||||
|
||||
if remaining_size.is_zero() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if remaining_size > Decimal::ZERO {
|
||||
return None; // Not enough liquidity
|
||||
}
|
||||
|
||||
let avg_price = weighted_price / size;
|
||||
let impact = match side {
|
||||
Side::BUY => {
|
||||
let best_ask = self.best_ask()?.price;
|
||||
(avg_price - best_ask) / best_ask
|
||||
}
|
||||
Side::SELL => {
|
||||
let best_bid = self.best_bid()?.price;
|
||||
(best_bid - avg_price) / best_bid
|
||||
}
|
||||
};
|
||||
|
||||
Some(MarketImpact {
|
||||
average_price: avg_price,
|
||||
impact_pct: impact,
|
||||
total_cost,
|
||||
size_filled: size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the book is stale (no recent updates)
|
||||
pub fn is_stale(&self, max_age: std::time::Duration) -> bool {
|
||||
let age = Utc::now() - self.timestamp;
|
||||
age > chrono::Duration::from_std(max_age).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get the total liquidity at a given price level
|
||||
pub fn liquidity_at_price(&self, price: Decimal, side: Side) -> Decimal {
|
||||
match side {
|
||||
Side::BUY => self.asks.get(&price).copied().unwrap_or_default(),
|
||||
Side::SELL => self.bids.get(&price).copied().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the total liquidity within a price range
|
||||
pub fn liquidity_in_range(&self, min_price: Decimal, max_price: Decimal, side: Side) -> Decimal {
|
||||
let levels: Vec<_> = match side {
|
||||
Side::BUY => self.asks.range(min_price..=max_price).collect(),
|
||||
Side::SELL => self.bids.range(min_price..=max_price).rev().collect(),
|
||||
};
|
||||
|
||||
levels.into_iter().map(|(_, &size)| size).sum()
|
||||
}
|
||||
|
||||
/// Validate that prices are properly ordered
|
||||
pub fn is_valid(&self) -> bool {
|
||||
match (self.best_bid(), self.best_ask()) {
|
||||
(Some(bid), Some(ask)) => bid.price < ask.price,
|
||||
_ => true, // Empty book is valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Market impact calculation result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarketImpact {
|
||||
pub average_price: Decimal,
|
||||
pub impact_pct: Decimal,
|
||||
pub total_cost: Decimal,
|
||||
pub size_filled: Decimal,
|
||||
}
|
||||
|
||||
/// Thread-safe order book manager
|
||||
#[derive(Debug)]
|
||||
pub struct OrderBookManager {
|
||||
books: Arc<RwLock<std::collections::HashMap<String, OrderBook>>>,
|
||||
max_depth: usize,
|
||||
}
|
||||
|
||||
impl OrderBookManager {
|
||||
/// Create a new order book manager
|
||||
pub fn new(max_depth: usize) -> Self {
|
||||
Self {
|
||||
books: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
max_depth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create an order book for a token
|
||||
pub fn get_or_create_book(&self, token_id: &str) -> Result<OrderBook> {
|
||||
let mut books = self.books.write().map_err(|_| {
|
||||
PolyfillError::internal_simple("Failed to acquire book lock")
|
||||
})?;
|
||||
|
||||
if let Some(book) = books.get(token_id) {
|
||||
Ok(book.clone())
|
||||
} else {
|
||||
let book = OrderBook::new(token_id.to_string(), self.max_depth);
|
||||
books.insert(token_id.to_string(), book.clone());
|
||||
Ok(book)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a book with a delta
|
||||
pub fn apply_delta(&self, delta: OrderDelta) -> Result<()> {
|
||||
let mut books = self.books.write().map_err(|_| {
|
||||
PolyfillError::internal_simple("Failed to acquire book lock")
|
||||
})?;
|
||||
|
||||
let book = books
|
||||
.get_mut(&delta.token_id)
|
||||
.ok_or_else(|| {
|
||||
PolyfillError::market_data(
|
||||
format!("No book found for token: {}", delta.token_id),
|
||||
crate::errors::MarketDataErrorKind::TokenNotFound,
|
||||
)
|
||||
})?;
|
||||
|
||||
book.apply_delta(delta)
|
||||
}
|
||||
|
||||
/// Get a book snapshot
|
||||
pub fn get_book(&self, token_id: &str) -> Result<crate::types::OrderBook> {
|
||||
let books = self.books.read().map_err(|_| {
|
||||
PolyfillError::internal_simple("Failed to acquire book lock")
|
||||
})?;
|
||||
|
||||
books
|
||||
.get(token_id)
|
||||
.map(|book| book.snapshot())
|
||||
.ok_or_else(|| {
|
||||
PolyfillError::market_data(
|
||||
format!("No book found for token: {}", token_id),
|
||||
crate::errors::MarketDataErrorKind::TokenNotFound,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all available books
|
||||
pub fn get_all_books(&self) -> Result<Vec<crate::types::OrderBook>> {
|
||||
let books = self.books.read().map_err(|_| {
|
||||
PolyfillError::internal_simple("Failed to acquire book lock")
|
||||
})?;
|
||||
|
||||
Ok(books.values().map(|book| book.snapshot()).collect())
|
||||
}
|
||||
|
||||
/// Remove stale books
|
||||
pub fn cleanup_stale_books(&self, max_age: std::time::Duration) -> Result<usize> {
|
||||
let mut books = self.books.write().map_err(|_| {
|
||||
PolyfillError::internal_simple("Failed to acquire book lock")
|
||||
})?;
|
||||
|
||||
let initial_count = books.len();
|
||||
books.retain(|_, book| !book.is_stale(max_age));
|
||||
let removed = initial_count - books.len();
|
||||
|
||||
if removed > 0 {
|
||||
debug!("Removed {} stale order books", removed);
|
||||
}
|
||||
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Order book analytics and statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BookAnalytics {
|
||||
pub token_id: String,
|
||||
pub timestamp: chrono::DateTime<Utc>,
|
||||
pub bid_count: usize,
|
||||
pub ask_count: usize,
|
||||
pub total_bid_size: Decimal,
|
||||
pub total_ask_size: Decimal,
|
||||
pub spread: Option<Decimal>,
|
||||
pub spread_pct: Option<Decimal>,
|
||||
pub mid_price: Option<Decimal>,
|
||||
pub volatility: Option<Decimal>,
|
||||
}
|
||||
|
||||
impl OrderBook {
|
||||
/// Calculate analytics for this book
|
||||
pub fn analytics(&self) -> BookAnalytics {
|
||||
let bid_count = self.bids.len();
|
||||
let ask_count = self.asks.len();
|
||||
let total_bid_size: Decimal = self.bids.values().sum();
|
||||
let total_ask_size: Decimal = self.asks.values().sum();
|
||||
|
||||
BookAnalytics {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: self.timestamp,
|
||||
bid_count,
|
||||
ask_count,
|
||||
total_bid_size,
|
||||
total_ask_size,
|
||||
spread: self.spread(),
|
||||
spread_pct: self.spread_pct(),
|
||||
mid_price: self.mid_price(),
|
||||
volatility: self.calculate_volatility(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate price volatility (simplified)
|
||||
fn calculate_volatility(&self) -> Option<Decimal> {
|
||||
// This is a simplified volatility calculation
|
||||
// In a real implementation, you'd want to track price history
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
#[test]
|
||||
fn test_order_book_creation() {
|
||||
let book = OrderBook::new("test_token".to_string(), 10);
|
||||
assert_eq!(book.token_id, "test_token");
|
||||
assert_eq!(book.bids.len(), 0);
|
||||
assert_eq!(book.asks.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_delta() {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 10);
|
||||
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: dec!(0.5),
|
||||
size: dec!(100),
|
||||
sequence: 1,
|
||||
};
|
||||
|
||||
book.apply_delta(delta).unwrap();
|
||||
assert_eq!(book.sequence, 1);
|
||||
assert_eq!(book.best_bid().unwrap().price, dec!(0.5));
|
||||
assert_eq!(book.best_bid().unwrap().size, dec!(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spread_calculation() {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 10);
|
||||
|
||||
// Add bid
|
||||
book.apply_delta(OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: dec!(0.5),
|
||||
size: dec!(100),
|
||||
sequence: 1,
|
||||
}).unwrap();
|
||||
|
||||
// Add ask
|
||||
book.apply_delta(OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: dec!(0.52),
|
||||
size: dec!(100),
|
||||
sequence: 2,
|
||||
}).unwrap();
|
||||
|
||||
let spread = book.spread().unwrap();
|
||||
assert_eq!(spread, dec!(0.02));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_market_impact() {
|
||||
let mut book = OrderBook::new("test_token".to_string(), 10);
|
||||
|
||||
// Add multiple ask levels
|
||||
for (i, price) in [dec!(0.50), dec!(0.51), dec!(0.52)].iter().enumerate() {
|
||||
book.apply_delta(OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: *price,
|
||||
size: dec!(100),
|
||||
sequence: i as u64 + 1,
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
let impact = book.calculate_market_impact(Side::BUY, dec!(150)).unwrap();
|
||||
assert!(impact.average_price > dec!(0.50));
|
||||
assert!(impact.average_price < dec!(0.51));
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
//! High-performance Rust client for Polymarket
|
||||
//!
|
||||
//! This module provides a production-ready client for interacting with
|
||||
//! Polymarket, optimized for high-frequency trading environments.
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::str::FromStr;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::prelude::FromPrimitive;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
// Re-export types for compatibility
|
||||
pub use crate::types::{
|
||||
ApiCredentials as ApiCreds, Side, OrderType,
|
||||
};
|
||||
|
||||
// Compatibility types
|
||||
#[derive(Debug)]
|
||||
pub struct OrderArgs {
|
||||
pub token_id: String,
|
||||
pub price: Decimal,
|
||||
pub size: Decimal,
|
||||
pub side: Side,
|
||||
}
|
||||
|
||||
impl OrderArgs {
|
||||
pub fn new(token_id: &str, price: Decimal, size: Decimal, side: Side) -> Self {
|
||||
Self {
|
||||
token_id: token_id.to_string(),
|
||||
price,
|
||||
size,
|
||||
side,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrderArgs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
token_id: "".to_string(),
|
||||
price: Decimal::ZERO,
|
||||
size: Decimal::ZERO,
|
||||
side: Side::BUY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main client for interacting with Polymarket API
|
||||
pub struct ClobClient {
|
||||
http_client: Client,
|
||||
base_url: String,
|
||||
chain_id: u64,
|
||||
}
|
||||
|
||||
impl ClobClient {
|
||||
/// Create a new client
|
||||
pub fn new(host: &str) -> Self {
|
||||
Self {
|
||||
http_client: Client::new(),
|
||||
base_url: host.to_string(),
|
||||
chain_id: 137, // Default to Polygon
|
||||
}
|
||||
}
|
||||
|
||||
/// Test basic connectivity
|
||||
pub async fn get_ok(&self) -> bool {
|
||||
match self.http_client.get(&format!("{}/ok", self.base_url)).send().await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server time
|
||||
pub async fn get_server_time(&self) -> Result<u64> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/time", self.base_url))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get server time"));
|
||||
}
|
||||
|
||||
let time_text = response.text().await?;
|
||||
let timestamp = time_text.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|e| PolyfillError::parse(format!("Invalid timestamp format: {}", e), None))?;
|
||||
|
||||
Ok(timestamp)
|
||||
}
|
||||
|
||||
/// Get sampling markets
|
||||
pub async fn get_sampling_markets(&self, _limit: Option<u32>) -> Result<MarketsResponse> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/sampling-markets", self.base_url))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get sampling markets"));
|
||||
}
|
||||
|
||||
let markets_response: MarketsResponse = response.json().await?;
|
||||
Ok(markets_response)
|
||||
}
|
||||
|
||||
/// Get order book for a token
|
||||
pub async fn get_order_book(&self, token_id: &str) -> Result<OrderBookSummary> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/book", self.base_url))
|
||||
.query(&[("token_id", token_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get order book"));
|
||||
}
|
||||
|
||||
let order_book: OrderBookSummary = response.json().await?;
|
||||
Ok(order_book)
|
||||
}
|
||||
|
||||
/// Get midpoint for a token
|
||||
pub async fn get_midpoint(&self, token_id: &str) -> Result<MidpointResponse> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/midpoint", self.base_url))
|
||||
.query(&[("token_id", token_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get midpoint"));
|
||||
}
|
||||
|
||||
let midpoint: MidpointResponse = response.json().await?;
|
||||
Ok(midpoint)
|
||||
}
|
||||
|
||||
/// Get spread for a token
|
||||
pub async fn get_spread(&self, token_id: &str) -> Result<SpreadResponse> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/spread", self.base_url))
|
||||
.query(&[("token_id", token_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get spread"));
|
||||
}
|
||||
|
||||
let spread: SpreadResponse = response.json().await?;
|
||||
Ok(spread)
|
||||
}
|
||||
|
||||
/// Get price for a token and side
|
||||
pub async fn get_price(&self, token_id: &str, side: Side) -> Result<PriceResponse> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/price", self.base_url))
|
||||
.query(&[
|
||||
("token_id", token_id),
|
||||
("side", side.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get price"));
|
||||
}
|
||||
|
||||
let price: PriceResponse = response.json().await?;
|
||||
Ok(price)
|
||||
}
|
||||
|
||||
/// Get tick size for a token
|
||||
pub async fn get_tick_size(&self, token_id: &str) -> Result<Decimal> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/tick-size", self.base_url))
|
||||
.query(&[("token_id", token_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get tick size"));
|
||||
}
|
||||
|
||||
let tick_size_response: Value = response.json().await?;
|
||||
let tick_size = tick_size_response["minimum_tick_size"]
|
||||
.as_str()
|
||||
.and_then(|s| Decimal::from_str(s).ok())
|
||||
.or_else(|| tick_size_response["minimum_tick_size"].as_f64().map(|f| Decimal::from_f64(f).unwrap_or(Decimal::ZERO)))
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid tick size format", None))?;
|
||||
|
||||
Ok(tick_size)
|
||||
}
|
||||
|
||||
/// Get neg risk for a token
|
||||
pub async fn get_neg_risk(&self, token_id: &str) -> Result<bool> {
|
||||
let response = self.http_client
|
||||
.get(&format!("{}/neg-risk", self.base_url))
|
||||
.query(&[("token_id", token_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PolyfillError::api(response.status().as_u16(), "Failed to get neg risk"));
|
||||
}
|
||||
|
||||
let neg_risk_response: Value = response.json().await?;
|
||||
let neg_risk = neg_risk_response["neg_risk"]
|
||||
.as_bool()
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid neg risk format", None))?;
|
||||
|
||||
Ok(neg_risk)
|
||||
}
|
||||
}
|
||||
|
||||
// Response types for API calls
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct MarketsResponse {
|
||||
pub limit: Decimal,
|
||||
pub count: Decimal,
|
||||
pub next_cursor: Option<String>,
|
||||
pub data: Vec<Market>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Market {
|
||||
pub condition_id: String,
|
||||
pub tokens: [Token; 2],
|
||||
pub rewards: Rewards,
|
||||
pub min_incentive_size: Option<String>,
|
||||
pub max_incentive_spread: Option<String>,
|
||||
pub active: bool,
|
||||
pub closed: bool,
|
||||
pub question_id: String,
|
||||
pub minimum_order_size: Decimal,
|
||||
pub minimum_tick_size: Decimal,
|
||||
pub description: String,
|
||||
pub category: Option<String>,
|
||||
pub end_date_iso: Option<String>,
|
||||
pub game_start_time: Option<String>,
|
||||
pub question: String,
|
||||
pub market_slug: String,
|
||||
pub seconds_delay: Decimal,
|
||||
pub icon: String,
|
||||
pub fpmm: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Token {
|
||||
pub token_id: String,
|
||||
pub outcome: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Rewards {
|
||||
pub rates: Option<serde_json::Value>,
|
||||
pub min_size: Decimal,
|
||||
pub max_spread: Decimal,
|
||||
pub event_start_date: Option<String>,
|
||||
pub event_end_date: Option<String>,
|
||||
pub in_game_multiplier: Option<Decimal>,
|
||||
pub reward_epoch: Option<Decimal>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct OrderBookSummary {
|
||||
pub market: String,
|
||||
pub asset_id: String,
|
||||
pub hash: String,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
pub bids: Vec<OrderSummary>,
|
||||
pub asks: Vec<OrderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct OrderSummary {
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub size: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct MidpointResponse {
|
||||
pub mid: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SpreadResponse {
|
||||
pub spread: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct PriceResponse {
|
||||
pub price: Decimal,
|
||||
}
|
||||
|
||||
// Additional types for full compatibility with polymarket-rs-client
|
||||
#[derive(Debug)]
|
||||
pub struct MarketOrderArgs {
|
||||
pub token_id: String,
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExtraOrderArgs {
|
||||
pub fee_rate_bps: u32,
|
||||
pub nonce: alloy_primitives::U256,
|
||||
pub taker: String,
|
||||
}
|
||||
|
||||
impl Default for ExtraOrderArgs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fee_rate_bps: 0,
|
||||
nonce: alloy_primitives::U256::ZERO,
|
||||
taker: "0x0000000000000000000000000000000000000000".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CreateOrderOptions {
|
||||
pub tick_size: Option<Decimal>,
|
||||
pub neg_risk: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct TickSizeResponse {
|
||||
pub minimum_tick_size: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct NegRiskResponse {
|
||||
pub neg_risk: bool,
|
||||
}
|
||||
|
||||
// Re-export for compatibility
|
||||
pub type PolyfillClient = ClobClient;
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
//! Contract configuration for Polymarket
|
||||
//!
|
||||
//! This module contains contract addresses and configuration for different
|
||||
//! networks and environments.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Contract configuration for a specific network
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContractConfig {
|
||||
pub exchange: String,
|
||||
pub collateral: String,
|
||||
pub conditional_tokens: String,
|
||||
}
|
||||
|
||||
/// Get contract configuration for a specific chain and risk setting
|
||||
pub fn get_contract_config(chain_id: u64, neg_risk: bool) -> Option<ContractConfig> {
|
||||
match neg_risk {
|
||||
true => {
|
||||
if chain_id == 137 {
|
||||
return Some(ContractConfig {
|
||||
exchange: "0xC5d563A36AE78145C45a50134d48A1215220f80a".to_owned(),
|
||||
collateral: "0x2791bca1f2de4661ed88a30c99a7a9449aa84174".to_owned(),
|
||||
conditional_tokens: "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045".to_owned(),
|
||||
});
|
||||
} else if chain_id == 80002 {
|
||||
return Some(ContractConfig {
|
||||
exchange: "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296".to_owned(),
|
||||
collateral: "0x9c4e1703476e875070ee25b56a58b008cfb8fa78".to_owned(),
|
||||
conditional_tokens: "0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB".to_owned(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
false => {
|
||||
if chain_id == 137 {
|
||||
return Some(ContractConfig {
|
||||
exchange: "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E".to_owned(),
|
||||
collateral: "0x2791Bca1f2de4661ED88A30C99a7a9449Aa84174".to_owned(),
|
||||
conditional_tokens: "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045".to_owned(),
|
||||
});
|
||||
} else if chain_id == 80002 {
|
||||
return Some(ContractConfig {
|
||||
exchange: "0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40".to_owned(),
|
||||
collateral: "0x9c4e1703476e875070ee25b56a58b008cfb8fa78".to_owned(),
|
||||
conditional_tokens: "0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB".to_owned(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Network configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkConfig {
|
||||
pub chain_id: u64,
|
||||
pub name: String,
|
||||
pub rpc_url: String,
|
||||
pub block_explorer: String,
|
||||
pub contracts: HashMap<String, ContractConfig>,
|
||||
}
|
||||
|
||||
impl NetworkConfig {
|
||||
/// Get configuration for Polygon mainnet
|
||||
pub fn polygon_mainnet() -> Self {
|
||||
let mut contracts = HashMap::new();
|
||||
contracts.insert("standard".to_string(), get_contract_config(137, false).unwrap());
|
||||
contracts.insert("neg_risk".to_string(), get_contract_config(137, true).unwrap());
|
||||
|
||||
Self {
|
||||
chain_id: 137,
|
||||
name: "Polygon Mainnet".to_string(),
|
||||
rpc_url: "https://polygon-rpc.com".to_string(),
|
||||
block_explorer: "https://polygonscan.com".to_string(),
|
||||
contracts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get configuration for Polygon Mumbai testnet
|
||||
pub fn polygon_mumbai() -> Self {
|
||||
let mut contracts = HashMap::new();
|
||||
contracts.insert("standard".to_string(), get_contract_config(80002, false).unwrap());
|
||||
contracts.insert("neg_risk".to_string(), get_contract_config(80002, true).unwrap());
|
||||
|
||||
Self {
|
||||
chain_id: 80002,
|
||||
name: "Polygon Mumbai".to_string(),
|
||||
rpc_url: "https://rpc-mumbai.maticvigil.com".to_string(),
|
||||
block_explorer: "https://mumbai.polygonscan.com".to_string(),
|
||||
contracts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get contract configuration for this network
|
||||
pub fn get_contract(&self, risk_type: &str) -> Option<&ContractConfig> {
|
||||
self.contracts.get(risk_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GlobalConfig {
|
||||
pub networks: HashMap<u64, NetworkConfig>,
|
||||
pub default_network: u64,
|
||||
}
|
||||
|
||||
impl GlobalConfig {
|
||||
/// Create default configuration
|
||||
pub fn new() -> Self {
|
||||
let mut networks = HashMap::new();
|
||||
networks.insert(137, NetworkConfig::polygon_mainnet());
|
||||
networks.insert(80002, NetworkConfig::polygon_mumbai());
|
||||
|
||||
Self {
|
||||
networks,
|
||||
default_network: 137,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get network configuration
|
||||
pub fn get_network(&self, chain_id: u64) -> Option<&NetworkConfig> {
|
||||
self.networks.get(&chain_id)
|
||||
}
|
||||
|
||||
/// Get default network
|
||||
pub fn default_network(&self) -> Option<&NetworkConfig> {
|
||||
self.networks.get(&self.default_network)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GlobalConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_contract_config() {
|
||||
let config = get_contract_config(137, false);
|
||||
assert!(config.is_some());
|
||||
|
||||
let config = config.unwrap();
|
||||
assert!(!config.exchange.is_empty());
|
||||
assert!(!config.collateral.is_empty());
|
||||
assert!(!config.conditional_tokens.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_config() {
|
||||
let polygon = NetworkConfig::polygon_mainnet();
|
||||
assert_eq!(polygon.chain_id, 137);
|
||||
assert_eq!(polygon.name, "Polygon Mainnet");
|
||||
|
||||
let contract = polygon.get_contract("standard");
|
||||
assert!(contract.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_config() {
|
||||
let config = GlobalConfig::new();
|
||||
assert_eq!(config.default_network, 137);
|
||||
|
||||
let network = config.get_network(137);
|
||||
assert!(network.is_some());
|
||||
}
|
||||
}
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
//! Data decoding utilities for Polymarket client
|
||||
//!
|
||||
//! This module provides high-performance decoding functions for various
|
||||
//! data formats used in trading environments.
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
use alloy_primitives::{Address, U256};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Fast string to number deserializers
|
||||
pub mod deserializers {
|
||||
use super::*;
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Deserialize number from string or number
|
||||
pub fn number_from_string<'de, T, D>(deserializer: D) -> std::result::Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: FromStr + serde::Deserialize<'de> + Clone,
|
||||
<T as FromStr>::Err: Display,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(v) = n.as_u64() {
|
||||
T::deserialize(serde_json::Value::Number(serde_json::Number::from(v)))
|
||||
.map_err(|_| serde::de::Error::custom("Failed to deserialize number"))
|
||||
} else if let Some(v) = n.as_f64() {
|
||||
T::deserialize(serde_json::Value::Number(serde_json::Number::from_f64(v).unwrap()))
|
||||
.map_err(|_| serde::de::Error::custom("Failed to deserialize number"))
|
||||
} else {
|
||||
Err(serde::de::Error::custom("Invalid number format"))
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
s.parse::<T>().map_err(serde::de::Error::custom)
|
||||
}
|
||||
_ => Err(serde::de::Error::custom("Expected number or string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize optional number from string
|
||||
pub fn optional_number_from_string<'de, T, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: FromStr + serde::Deserialize<'de> + Clone,
|
||||
<T as FromStr>::Err: Display,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(None),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(v) = n.as_u64() {
|
||||
T::deserialize(serde_json::Value::Number(serde_json::Number::from(v)))
|
||||
.map(Some)
|
||||
.map_err(|_| serde::de::Error::custom("Failed to deserialize number"))
|
||||
} else if let Some(v) = n.as_f64() {
|
||||
T::deserialize(serde_json::Value::Number(serde_json::Number::from_f64(v).unwrap()))
|
||||
.map(Some)
|
||||
.map_err(|_| serde::de::Error::custom("Failed to deserialize number"))
|
||||
} else {
|
||||
Err(serde::de::Error::custom("Invalid number format"))
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
if s.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
s.parse::<T>()
|
||||
.map(Some)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom("Expected number, string, or null")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize DateTime from Unix timestamp
|
||||
pub fn datetime_from_timestamp<'de, D>(deserializer: D) -> std::result::Result<DateTime<Utc>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let timestamp = number_from_string::<u64, D>(deserializer)?;
|
||||
DateTime::from_timestamp(timestamp as i64, 0)
|
||||
.ok_or_else(|| serde::de::Error::custom("Invalid timestamp"))
|
||||
}
|
||||
|
||||
/// Deserialize optional DateTime from Unix timestamp
|
||||
pub fn optional_datetime_from_timestamp<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<DateTime<Utc>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match optional_number_from_string::<u64, D>(deserializer)? {
|
||||
Some(timestamp) => DateTime::from_timestamp(timestamp as i64, 0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| serde::de::Error::custom("Invalid timestamp")),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw API response types for efficient parsing
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawOrderBookResponse {
|
||||
pub market: String,
|
||||
pub asset_id: String,
|
||||
pub hash: String,
|
||||
#[serde(deserialize_with = "deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
pub bids: Vec<RawBookLevel>,
|
||||
pub asks: Vec<RawBookLevel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawBookLevel {
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub size: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawOrderResponse {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub market: String,
|
||||
pub asset_id: String,
|
||||
pub maker_address: String,
|
||||
pub owner: String,
|
||||
pub outcome: String,
|
||||
#[serde(rename = "type")]
|
||||
pub order_type: OrderType,
|
||||
pub side: Side,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub original_size: Decimal,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub size_matched: Decimal,
|
||||
#[serde(deserialize_with = "deserializers::number_from_string")]
|
||||
pub expiration: u64,
|
||||
#[serde(deserialize_with = "deserializers::number_from_string")]
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawTradeResponse {
|
||||
pub id: String,
|
||||
pub market: String,
|
||||
pub asset_id: String,
|
||||
pub side: Side,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub size: Decimal,
|
||||
pub maker_address: String,
|
||||
pub taker_address: String,
|
||||
#[serde(deserialize_with = "deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawMarketResponse {
|
||||
pub condition_id: String,
|
||||
pub tokens: [RawToken; 2],
|
||||
pub active: bool,
|
||||
pub closed: bool,
|
||||
pub question: String,
|
||||
pub description: String,
|
||||
pub category: Option<String>,
|
||||
pub end_date_iso: Option<String>,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub minimum_order_size: Decimal,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub minimum_tick_size: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawToken {
|
||||
pub token_id: String,
|
||||
pub outcome: String,
|
||||
}
|
||||
|
||||
/// Decoder implementations for converting raw responses to client types
|
||||
pub trait Decoder<T> {
|
||||
fn decode(&self) -> Result<T>;
|
||||
}
|
||||
|
||||
impl Decoder<OrderBook> for RawOrderBookResponse {
|
||||
fn decode(&self) -> Result<OrderBook> {
|
||||
let timestamp = chrono::DateTime::from_timestamp(self.timestamp as i64, 0)
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid timestamp".to_string(), None))?;
|
||||
|
||||
let bids = self
|
||||
.bids
|
||||
.iter()
|
||||
.map(|level| BookLevel {
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let asks = self
|
||||
.asks
|
||||
.iter()
|
||||
.map(|level| BookLevel {
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(OrderBook {
|
||||
token_id: self.asset_id.clone(),
|
||||
timestamp,
|
||||
bids,
|
||||
asks,
|
||||
sequence: 0, // TODO: Get from response if available
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoder<Order> for RawOrderResponse {
|
||||
fn decode(&self) -> Result<Order> {
|
||||
let status = match self.status.as_str() {
|
||||
"LIVE" => OrderStatus::Live,
|
||||
"CANCELLED" => OrderStatus::Cancelled,
|
||||
"FILLED" => OrderStatus::Filled,
|
||||
"PARTIAL" => OrderStatus::Partial,
|
||||
"EXPIRED" => OrderStatus::Expired,
|
||||
_ => return Err(PolyfillError::parse(
|
||||
format!("Unknown order status: {}", self.status),
|
||||
None,
|
||||
)),
|
||||
};
|
||||
|
||||
let created_at = chrono::DateTime::from_timestamp(self.created_at as i64, 0)
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid created_at timestamp".to_string(), None))?;
|
||||
|
||||
let expiration = if self.expiration > 0 {
|
||||
Some(chrono::DateTime::from_timestamp(self.expiration as i64, 0)
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid expiration timestamp".to_string(), None))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Order {
|
||||
id: self.id.clone(),
|
||||
token_id: self.asset_id.clone(),
|
||||
side: self.side,
|
||||
price: self.price,
|
||||
original_size: self.original_size,
|
||||
filled_size: self.size_matched,
|
||||
remaining_size: self.original_size - self.size_matched,
|
||||
status,
|
||||
order_type: self.order_type,
|
||||
created_at,
|
||||
updated_at: created_at, // Use same as created for now
|
||||
expiration,
|
||||
client_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoder<FillEvent> for RawTradeResponse {
|
||||
fn decode(&self) -> Result<FillEvent> {
|
||||
let timestamp = chrono::DateTime::from_timestamp(self.timestamp as i64, 0)
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid trade timestamp".to_string(), None))?;
|
||||
|
||||
let maker_address = Address::from_str(&self.maker_address)
|
||||
.map_err(|e| PolyfillError::parse(format!("Invalid maker address: {}", e), None))?;
|
||||
|
||||
let taker_address = Address::from_str(&self.taker_address)
|
||||
.map_err(|e| PolyfillError::parse(format!("Invalid taker address: {}", e), None))?;
|
||||
|
||||
Ok(FillEvent {
|
||||
id: self.id.clone(),
|
||||
order_id: "".to_string(), // TODO: Get from response if available
|
||||
token_id: self.asset_id.clone(),
|
||||
side: self.side,
|
||||
price: self.price,
|
||||
size: self.size,
|
||||
timestamp,
|
||||
maker_address,
|
||||
taker_address,
|
||||
fee: Decimal::ZERO, // TODO: Calculate or get from response
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoder<Market> for RawMarketResponse {
|
||||
fn decode(&self) -> Result<Market> {
|
||||
let tokens = [
|
||||
Token {
|
||||
token_id: self.tokens[0].token_id.clone(),
|
||||
outcome: self.tokens[0].outcome.clone(),
|
||||
},
|
||||
Token {
|
||||
token_id: self.tokens[1].token_id.clone(),
|
||||
outcome: self.tokens[1].outcome.clone(),
|
||||
},
|
||||
];
|
||||
|
||||
Ok(Market {
|
||||
condition_id: self.condition_id.clone(),
|
||||
tokens,
|
||||
active: self.active,
|
||||
closed: self.closed,
|
||||
question: self.question.clone(),
|
||||
description: self.description.clone(),
|
||||
category: self.category.clone(),
|
||||
end_date_iso: self.end_date_iso.clone(),
|
||||
minimum_order_size: self.minimum_order_size,
|
||||
minimum_tick_size: self.minimum_tick_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket message parsing
|
||||
pub fn parse_stream_message(raw: &str) -> Result<StreamMessage> {
|
||||
let value: Value = serde_json::from_str(raw)?;
|
||||
|
||||
let msg_type = value["type"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PolyfillError::parse("Missing message type".to_string(), None))?;
|
||||
|
||||
match msg_type {
|
||||
"book_update" => {
|
||||
let data = value["data"].clone();
|
||||
let delta: OrderDelta = serde_json::from_value(data)?;
|
||||
Ok(StreamMessage::BookUpdate { data: delta })
|
||||
}
|
||||
"trade" => {
|
||||
let data = value["data"].clone();
|
||||
let raw_trade: RawTradeResponse = serde_json::from_value(data)?;
|
||||
let fill = raw_trade.decode()?;
|
||||
Ok(StreamMessage::Trade { data: fill })
|
||||
}
|
||||
"order_update" => {
|
||||
let data = value["data"].clone();
|
||||
let raw_order: RawOrderResponse = serde_json::from_value(data)?;
|
||||
let order = raw_order.decode()?;
|
||||
Ok(StreamMessage::OrderUpdate { data: order })
|
||||
}
|
||||
"heartbeat" => {
|
||||
let timestamp = value["timestamp"]
|
||||
.as_str()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|| Utc::now());
|
||||
Ok(StreamMessage::Heartbeat { timestamp })
|
||||
}
|
||||
_ => Err(PolyfillError::parse(
|
||||
format!("Unknown message type: {}", msg_type),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch parsing utilities for high-throughput scenarios
|
||||
pub struct BatchDecoder {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BatchDecoder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buffer: Vec::with_capacity(8192),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse multiple JSON objects from a byte stream
|
||||
pub fn parse_json_stream<T>(&mut self, data: &[u8]) -> Result<Vec<T>>
|
||||
where
|
||||
T: for<'de> serde::Deserialize<'de>,
|
||||
{
|
||||
self.buffer.extend_from_slice(data);
|
||||
let mut results = Vec::new();
|
||||
let mut start = 0;
|
||||
|
||||
while let Some(end) = self.find_json_boundary(start) {
|
||||
let json_slice = &self.buffer[start..end];
|
||||
if let Ok(obj) = serde_json::from_slice::<T>(json_slice) {
|
||||
results.push(obj);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
|
||||
// Keep remaining incomplete data
|
||||
if start > 0 {
|
||||
self.buffer.drain(0..start);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Find the end of a JSON object in the buffer
|
||||
fn find_json_boundary(&self, start: usize) -> Option<usize> {
|
||||
let mut depth = 0;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for (i, &byte) in self.buffer[start..].iter().enumerate() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match byte {
|
||||
b'\\' if in_string => escaped = true,
|
||||
b'"' => in_string = !in_string,
|
||||
b'{' if !in_string => depth += 1,
|
||||
b'}' if !in_string => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(start + i + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BatchDecoder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimized parsers for common data types
|
||||
pub mod fast_parse {
|
||||
use super::*;
|
||||
|
||||
/// Fast decimal parsing for prices
|
||||
#[inline]
|
||||
pub fn parse_decimal(s: &str) -> Result<Decimal> {
|
||||
Decimal::from_str(s)
|
||||
.map_err(|e| PolyfillError::parse(format!("Invalid decimal: {}", e), None))
|
||||
}
|
||||
|
||||
/// Fast address parsing
|
||||
#[inline]
|
||||
pub fn parse_address(s: &str) -> Result<Address> {
|
||||
Address::from_str(s)
|
||||
.map_err(|e| PolyfillError::parse(format!("Invalid address: {}", e), None))
|
||||
}
|
||||
|
||||
/// Fast U256 parsing
|
||||
#[inline]
|
||||
pub fn parse_u256(s: &str) -> Result<U256> {
|
||||
U256::from_str_radix(s, 10)
|
||||
.map_err(|e| PolyfillError::parse(format!("Invalid U256: {}", e), None))
|
||||
}
|
||||
|
||||
/// Parse Side enum
|
||||
#[inline]
|
||||
pub fn parse_side(s: &str) -> Result<Side> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"BUY" => Ok(Side::BUY),
|
||||
"SELL" => Ok(Side::SELL),
|
||||
_ => Err(PolyfillError::parse(format!("Invalid side: {}", s), None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_decimal() {
|
||||
let result = fast_parse::parse_decimal("123.456").unwrap();
|
||||
assert_eq!(result, Decimal::from_str("123.456").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_side() {
|
||||
assert_eq!(fast_parse::parse_side("BUY").unwrap(), Side::BUY);
|
||||
assert_eq!(fast_parse::parse_side("sell").unwrap(), Side::SELL);
|
||||
assert!(fast_parse::parse_side("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_decoder() {
|
||||
let mut decoder = BatchDecoder::new();
|
||||
let data = r#"{"test":1}{"test":2}"#.as_bytes();
|
||||
|
||||
let results: Vec<serde_json::Value> = decoder.parse_json_stream(data).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
}
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
//! Error types for the Polymarket client
|
||||
//!
|
||||
//! This module defines all error types used throughout the client, designed
|
||||
//! for clear error handling in trading environments where fast error recovery
|
||||
//! is critical.
|
||||
|
||||
use thiserror::Error;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Main error type for the Polymarket client
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PolyfillError {
|
||||
/// Network-related errors (retryable)
|
||||
#[error("Network error: {message}")]
|
||||
Network {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// API errors from Polymarket
|
||||
#[error("API error ({status}): {message}")]
|
||||
Api {
|
||||
status: u16,
|
||||
message: String,
|
||||
error_code: Option<String>,
|
||||
},
|
||||
|
||||
/// Authentication/authorization errors
|
||||
#[error("Auth error: {message}")]
|
||||
Auth {
|
||||
message: String,
|
||||
kind: AuthErrorKind,
|
||||
},
|
||||
|
||||
/// Order-related errors
|
||||
#[error("Order error: {message}")]
|
||||
Order {
|
||||
message: String,
|
||||
kind: OrderErrorKind,
|
||||
},
|
||||
|
||||
/// Market data errors
|
||||
#[error("Market data error: {message}")]
|
||||
MarketData {
|
||||
message: String,
|
||||
kind: MarketDataErrorKind,
|
||||
},
|
||||
|
||||
/// Configuration errors
|
||||
#[error("Config error: {message}")]
|
||||
Config {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Parsing/serialization errors
|
||||
#[error("Parse error: {message}")]
|
||||
Parse {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// Timeout errors
|
||||
#[error("Timeout error: operation timed out after {duration:?}")]
|
||||
Timeout {
|
||||
duration: Duration,
|
||||
operation: String,
|
||||
},
|
||||
|
||||
/// Rate limiting errors
|
||||
#[error("Rate limit exceeded: {message}")]
|
||||
RateLimit {
|
||||
message: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
|
||||
/// WebSocket/streaming errors
|
||||
#[error("Stream error: {message}")]
|
||||
Stream {
|
||||
message: String,
|
||||
kind: StreamErrorKind,
|
||||
},
|
||||
|
||||
/// Validation errors
|
||||
#[error("Validation error: {message}")]
|
||||
Validation {
|
||||
message: String,
|
||||
field: Option<String>,
|
||||
},
|
||||
|
||||
/// Internal errors (bugs)
|
||||
#[error("Internal error: {message}")]
|
||||
Internal {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Authentication error subcategories
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthErrorKind {
|
||||
InvalidCredentials,
|
||||
ExpiredCredentials,
|
||||
InsufficientPermissions,
|
||||
SignatureError,
|
||||
NonceError,
|
||||
}
|
||||
|
||||
/// Order error subcategories
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OrderErrorKind {
|
||||
InvalidPrice,
|
||||
InvalidSize,
|
||||
InsufficientBalance,
|
||||
MarketClosed,
|
||||
DuplicateOrder,
|
||||
OrderNotFound,
|
||||
CancellationFailed,
|
||||
ExecutionFailed,
|
||||
SizeConstraint,
|
||||
PriceConstraint,
|
||||
}
|
||||
|
||||
/// Market data error subcategories
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MarketDataErrorKind {
|
||||
TokenNotFound,
|
||||
MarketNotFound,
|
||||
StaleData,
|
||||
IncompleteData,
|
||||
BookUnavailable,
|
||||
}
|
||||
|
||||
/// Streaming error subcategories
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum StreamErrorKind {
|
||||
ConnectionFailed,
|
||||
ConnectionLost,
|
||||
SubscriptionFailed,
|
||||
MessageCorrupted,
|
||||
Reconnecting,
|
||||
}
|
||||
|
||||
impl PolyfillError {
|
||||
/// Check if this error is retryable
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
PolyfillError::Network { .. } => true,
|
||||
PolyfillError::Api { status, .. } => {
|
||||
// 5xx errors are typically retryable
|
||||
*status >= 500 && *status < 600
|
||||
},
|
||||
PolyfillError::Timeout { .. } => true,
|
||||
PolyfillError::RateLimit { .. } => true,
|
||||
PolyfillError::Stream { kind, .. } => {
|
||||
matches!(kind, StreamErrorKind::ConnectionLost | StreamErrorKind::Reconnecting)
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggested retry delay
|
||||
pub fn retry_delay(&self) -> Option<Duration> {
|
||||
match self {
|
||||
PolyfillError::Network { .. } => Some(Duration::from_millis(100)),
|
||||
PolyfillError::Api { status, .. } => {
|
||||
if *status >= 500 {
|
||||
Some(Duration::from_millis(500))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
PolyfillError::Timeout { .. } => Some(Duration::from_millis(50)),
|
||||
PolyfillError::RateLimit { retry_after, .. } => {
|
||||
retry_after.or(Some(Duration::from_secs(1)))
|
||||
},
|
||||
PolyfillError::Stream { .. } => Some(Duration::from_millis(250)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a critical error that should stop trading
|
||||
pub fn is_critical(&self) -> bool {
|
||||
match self {
|
||||
PolyfillError::Auth { .. } => true,
|
||||
PolyfillError::Config { .. } => true,
|
||||
PolyfillError::Internal { .. } => true,
|
||||
PolyfillError::Order { kind, .. } => {
|
||||
matches!(kind, OrderErrorKind::InsufficientBalance)
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get error category for metrics
|
||||
pub fn category(&self) -> &'static str {
|
||||
match self {
|
||||
PolyfillError::Network { .. } => "network",
|
||||
PolyfillError::Api { .. } => "api",
|
||||
PolyfillError::Auth { .. } => "auth",
|
||||
PolyfillError::Order { .. } => "order",
|
||||
PolyfillError::MarketData { .. } => "market_data",
|
||||
PolyfillError::Config { .. } => "config",
|
||||
PolyfillError::Parse { .. } => "parse",
|
||||
PolyfillError::Timeout { .. } => "timeout",
|
||||
PolyfillError::RateLimit { .. } => "rate_limit",
|
||||
PolyfillError::Stream { .. } => "stream",
|
||||
PolyfillError::Validation { .. } => "validation",
|
||||
PolyfillError::Internal { .. } => "internal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience constructors
|
||||
impl PolyfillError {
|
||||
pub fn network<E: std::error::Error + Send + Sync + 'static>(
|
||||
message: impl Into<String>,
|
||||
source: E,
|
||||
) -> Self {
|
||||
Self::Network {
|
||||
message: message.into(),
|
||||
source: Some(Box::new(source)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api(status: u16, message: impl Into<String>) -> Self {
|
||||
Self::Api {
|
||||
status,
|
||||
message: message.into(),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth(message: impl Into<String>, kind: AuthErrorKind) -> Self {
|
||||
Self::Auth {
|
||||
message: message.into(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order(message: impl Into<String>, kind: OrderErrorKind) -> Self {
|
||||
Self::Order {
|
||||
message: message.into(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn market_data(message: impl Into<String>, kind: MarketDataErrorKind) -> Self {
|
||||
Self::MarketData {
|
||||
message: message.into(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(message: impl Into<String>) -> Self {
|
||||
Self::Config {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(message: impl Into<String>, source: Option<Box<dyn std::error::Error + Send + Sync>>) -> Self {
|
||||
Self::Parse {
|
||||
message: message.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timeout(duration: Duration, operation: impl Into<String>) -> Self {
|
||||
Self::Timeout {
|
||||
duration,
|
||||
operation: operation.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rate_limit(message: impl Into<String>) -> Self {
|
||||
Self::RateLimit {
|
||||
message: message.into(),
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream(message: impl Into<String>, kind: StreamErrorKind) -> Self {
|
||||
Self::Stream {
|
||||
message: message.into(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self::Validation {
|
||||
message: message.into(),
|
||||
field: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal<E: std::error::Error + Send + Sync + 'static>(
|
||||
message: impl Into<String>,
|
||||
source: E,
|
||||
) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
source: Some(Box::new(source)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_simple(message: impl Into<String>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement From for common external error types
|
||||
impl From<reqwest::Error> for PolyfillError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_timeout() {
|
||||
PolyfillError::Timeout {
|
||||
duration: Duration::from_secs(30), // default timeout
|
||||
operation: "HTTP request".to_string(),
|
||||
}
|
||||
} else if err.is_connect() || err.is_request() {
|
||||
PolyfillError::network("HTTP request failed", err)
|
||||
} else {
|
||||
PolyfillError::internal("Unexpected reqwest error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for PolyfillError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
PolyfillError::Parse {
|
||||
message: format!("JSON parsing failed: {}", err),
|
||||
source: Some(Box::new(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for PolyfillError {
|
||||
fn from(err: url::ParseError) -> Self {
|
||||
PolyfillError::config(format!("Invalid URL: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "stream")]
|
||||
impl From<tokio_tungstenite::tungstenite::Error> for PolyfillError {
|
||||
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
|
||||
let kind = match &err {
|
||||
WsError::ConnectionClosed | WsError::AlreadyClosed => StreamErrorKind::ConnectionLost,
|
||||
WsError::Io(_) => StreamErrorKind::ConnectionFailed,
|
||||
WsError::Protocol(_) => StreamErrorKind::MessageCorrupted,
|
||||
_ => StreamErrorKind::ConnectionFailed,
|
||||
};
|
||||
|
||||
PolyfillError::stream(format!("WebSocket error: {}", err), kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Clone implementation since Box<dyn Error> doesn't implement Clone
|
||||
impl Clone for PolyfillError {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
PolyfillError::Network { message, source: _ } => {
|
||||
PolyfillError::Network {
|
||||
message: message.clone(),
|
||||
source: None
|
||||
}
|
||||
}
|
||||
PolyfillError::Api { status, message, error_code } => {
|
||||
PolyfillError::Api {
|
||||
status: *status,
|
||||
message: message.clone(),
|
||||
error_code: error_code.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::Auth { message, kind } => {
|
||||
PolyfillError::Auth {
|
||||
message: message.clone(),
|
||||
kind: kind.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::Order { message, kind } => {
|
||||
PolyfillError::Order {
|
||||
message: message.clone(),
|
||||
kind: kind.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::MarketData { message, kind } => {
|
||||
PolyfillError::MarketData {
|
||||
message: message.clone(),
|
||||
kind: kind.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::Config { message } => {
|
||||
PolyfillError::Config {
|
||||
message: message.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::Parse { message, source: _ } => {
|
||||
PolyfillError::Parse {
|
||||
message: message.clone(),
|
||||
source: None
|
||||
}
|
||||
}
|
||||
PolyfillError::Timeout { duration, operation } => {
|
||||
PolyfillError::Timeout {
|
||||
duration: *duration,
|
||||
operation: operation.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::RateLimit { message, retry_after } => {
|
||||
PolyfillError::RateLimit {
|
||||
message: message.clone(),
|
||||
retry_after: *retry_after
|
||||
}
|
||||
}
|
||||
PolyfillError::Stream { message, kind } => {
|
||||
PolyfillError::Stream {
|
||||
message: message.clone(),
|
||||
kind: kind.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::Validation { message, field } => {
|
||||
PolyfillError::Validation {
|
||||
message: message.clone(),
|
||||
field: field.clone()
|
||||
}
|
||||
}
|
||||
PolyfillError::Internal { message, source: _ } => {
|
||||
PolyfillError::Internal {
|
||||
message: message.clone(),
|
||||
source: None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for convenience
|
||||
pub type Result<T> = std::result::Result<T, PolyfillError>;
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
//! Trade execution and fill handling for Polymarket client
|
||||
//!
|
||||
//! This module provides high-performance trade execution logic and
|
||||
//! fill event processing for latency-sensitive trading environments.
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
use crate::utils::math;
|
||||
use alloy_primitives::Address;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Fill execution result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FillResult {
|
||||
pub order_id: String,
|
||||
pub fills: Vec<FillEvent>,
|
||||
pub total_size: Decimal,
|
||||
pub average_price: Decimal,
|
||||
pub total_cost: Decimal,
|
||||
pub fees: Decimal,
|
||||
pub status: FillStatus,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Fill execution status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FillStatus {
|
||||
/// Order was fully filled
|
||||
Filled,
|
||||
/// Order was partially filled
|
||||
Partial,
|
||||
/// Order was not filled (insufficient liquidity)
|
||||
Unfilled,
|
||||
/// Order was rejected
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Fill execution engine
|
||||
#[derive(Debug)]
|
||||
pub struct FillEngine {
|
||||
/// Minimum fill size for market orders
|
||||
min_fill_size: Decimal,
|
||||
/// Maximum slippage tolerance (as percentage)
|
||||
max_slippage_pct: Decimal,
|
||||
/// Fee rate in basis points
|
||||
fee_rate_bps: u32,
|
||||
/// Track fills by order ID
|
||||
fills: HashMap<String, Vec<FillEvent>>,
|
||||
}
|
||||
|
||||
impl FillEngine {
|
||||
/// Create a new fill engine
|
||||
pub fn new(min_fill_size: Decimal, max_slippage_pct: Decimal, fee_rate_bps: u32) -> Self {
|
||||
Self {
|
||||
min_fill_size,
|
||||
max_slippage_pct,
|
||||
fee_rate_bps,
|
||||
fills: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a market order against an order book
|
||||
pub fn execute_market_order(
|
||||
&mut self,
|
||||
order: &MarketOrderRequest,
|
||||
book: &crate::book::OrderBook,
|
||||
) -> Result<FillResult> {
|
||||
let start_time = Utc::now();
|
||||
|
||||
// Validate order
|
||||
self.validate_market_order(order)?;
|
||||
|
||||
// Get available liquidity
|
||||
let levels = match order.side {
|
||||
Side::BUY => book.asks(None),
|
||||
Side::SELL => book.bids(None),
|
||||
};
|
||||
|
||||
if levels.is_empty() {
|
||||
return Ok(FillResult {
|
||||
order_id: order.client_id.clone().unwrap_or_else(|| "market_order".to_string()),
|
||||
fills: Vec::new(),
|
||||
total_size: Decimal::ZERO,
|
||||
average_price: Decimal::ZERO,
|
||||
total_cost: Decimal::ZERO,
|
||||
fees: Decimal::ZERO,
|
||||
status: FillStatus::Unfilled,
|
||||
timestamp: start_time,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute fills
|
||||
let mut fills = Vec::new();
|
||||
let mut remaining_size = order.amount;
|
||||
let mut total_cost = Decimal::ZERO;
|
||||
let mut total_size = Decimal::ZERO;
|
||||
|
||||
for level in levels {
|
||||
if remaining_size.is_zero() {
|
||||
break;
|
||||
}
|
||||
|
||||
let fill_size = std::cmp::min(remaining_size, level.size);
|
||||
let fill_cost = fill_size * level.price;
|
||||
|
||||
// Calculate fee
|
||||
let fee = self.calculate_fee(fill_cost);
|
||||
|
||||
let fill = FillEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
order_id: order.client_id.clone().unwrap_or_else(|| "market_order".to_string()),
|
||||
token_id: order.token_id.clone(),
|
||||
side: order.side,
|
||||
price: level.price,
|
||||
size: fill_size,
|
||||
timestamp: Utc::now(),
|
||||
maker_address: Address::ZERO, // TODO: Get from level
|
||||
taker_address: Address::ZERO, // TODO: Get from order
|
||||
fee,
|
||||
};
|
||||
|
||||
fills.push(fill);
|
||||
total_cost += fill_cost;
|
||||
total_size += fill_size;
|
||||
remaining_size -= fill_size;
|
||||
}
|
||||
|
||||
// Check slippage
|
||||
if let Some(slippage) = self.calculate_slippage(order, &fills) {
|
||||
if slippage > self.max_slippage_pct {
|
||||
warn!(
|
||||
"Slippage {}% exceeds maximum {}%",
|
||||
slippage, self.max_slippage_pct
|
||||
);
|
||||
return Ok(FillResult {
|
||||
order_id: order.client_id.clone().unwrap_or_else(|| "market_order".to_string()),
|
||||
fills: Vec::new(),
|
||||
total_size: Decimal::ZERO,
|
||||
average_price: Decimal::ZERO,
|
||||
total_cost: Decimal::ZERO,
|
||||
fees: Decimal::ZERO,
|
||||
status: FillStatus::Rejected,
|
||||
timestamp: start_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Determine status
|
||||
let status = if remaining_size.is_zero() {
|
||||
FillStatus::Filled
|
||||
} else if total_size >= self.min_fill_size {
|
||||
FillStatus::Partial
|
||||
} else {
|
||||
FillStatus::Unfilled
|
||||
};
|
||||
|
||||
let average_price = if total_size.is_zero() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
total_cost / total_size
|
||||
};
|
||||
|
||||
let total_fees: Decimal = fills.iter().map(|f| f.fee).sum();
|
||||
|
||||
let result = FillResult {
|
||||
order_id: order.client_id.clone().unwrap_or_else(|| "market_order".to_string()),
|
||||
fills,
|
||||
total_size,
|
||||
average_price,
|
||||
total_cost,
|
||||
fees: total_fees,
|
||||
status,
|
||||
timestamp: start_time,
|
||||
};
|
||||
|
||||
// Store fills for tracking
|
||||
if !result.fills.is_empty() {
|
||||
self.fills.insert(result.order_id.clone(), result.fills.clone());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Market order executed: {} {} @ {} (avg: {})",
|
||||
result.total_size,
|
||||
order.side.as_str(),
|
||||
order.amount,
|
||||
result.average_price
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Execute a limit order (simulation)
|
||||
pub fn execute_limit_order(
|
||||
&mut self,
|
||||
order: &OrderRequest,
|
||||
book: &crate::book::OrderBook,
|
||||
) -> Result<FillResult> {
|
||||
let start_time = Utc::now();
|
||||
|
||||
// Validate order
|
||||
self.validate_limit_order(order)?;
|
||||
|
||||
// Check if order can be filled immediately
|
||||
let can_fill = match order.side {
|
||||
Side::BUY => {
|
||||
if let Some(best_ask) = book.best_ask() {
|
||||
order.price >= best_ask.price
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Side::SELL => {
|
||||
if let Some(best_bid) = book.best_bid() {
|
||||
order.price <= best_bid.price
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !can_fill {
|
||||
return Ok(FillResult {
|
||||
order_id: order.client_id.clone().unwrap_or_else(|| "limit_order".to_string()),
|
||||
fills: Vec::new(),
|
||||
total_size: Decimal::ZERO,
|
||||
average_price: Decimal::ZERO,
|
||||
total_cost: Decimal::ZERO,
|
||||
fees: Decimal::ZERO,
|
||||
status: FillStatus::Unfilled,
|
||||
timestamp: start_time,
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate immediate fill
|
||||
let fill = FillEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
order_id: order.client_id.clone().unwrap_or_else(|| "limit_order".to_string()),
|
||||
token_id: order.token_id.clone(),
|
||||
side: order.side,
|
||||
price: order.price,
|
||||
size: order.size,
|
||||
timestamp: Utc::now(),
|
||||
maker_address: Address::ZERO,
|
||||
taker_address: Address::ZERO,
|
||||
fee: self.calculate_fee(order.price * order.size),
|
||||
};
|
||||
|
||||
let result = FillResult {
|
||||
order_id: order.client_id.clone().unwrap_or_else(|| "limit_order".to_string()),
|
||||
fills: vec![fill],
|
||||
total_size: order.size,
|
||||
average_price: order.price,
|
||||
total_cost: order.price * order.size,
|
||||
fees: self.calculate_fee(order.price * order.size),
|
||||
status: FillStatus::Filled,
|
||||
timestamp: start_time,
|
||||
};
|
||||
|
||||
// Store fills for tracking
|
||||
self.fills.insert(result.order_id.clone(), result.fills.clone());
|
||||
|
||||
info!(
|
||||
"Limit order executed: {} {} @ {}",
|
||||
result.total_size,
|
||||
order.side.as_str(),
|
||||
result.average_price
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Calculate slippage for a market order
|
||||
fn calculate_slippage(&self, order: &MarketOrderRequest, fills: &[FillEvent]) -> Option<Decimal> {
|
||||
if fills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total_cost: Decimal = fills.iter().map(|f| f.price * f.size).sum();
|
||||
let total_size: Decimal = fills.iter().map(|f| f.size).sum();
|
||||
let average_price = total_cost / total_size;
|
||||
|
||||
// Get reference price (best bid/ask)
|
||||
let reference_price = match order.side {
|
||||
Side::BUY => fills.first()?.price, // Best ask
|
||||
Side::SELL => fills.first()?.price, // Best bid
|
||||
};
|
||||
|
||||
Some(math::calculate_slippage(reference_price, average_price, order.side))
|
||||
}
|
||||
|
||||
/// Calculate fee for a trade
|
||||
fn calculate_fee(&self, notional: Decimal) -> Decimal {
|
||||
notional * Decimal::from(self.fee_rate_bps) / Decimal::from(10_000)
|
||||
}
|
||||
|
||||
/// Validate market order parameters
|
||||
fn validate_market_order(&self, order: &MarketOrderRequest) -> Result<()> {
|
||||
if order.amount.is_zero() {
|
||||
return Err(PolyfillError::order(
|
||||
"Market order amount cannot be zero",
|
||||
crate::errors::OrderErrorKind::InvalidSize,
|
||||
));
|
||||
}
|
||||
|
||||
if order.amount < self.min_fill_size {
|
||||
return Err(PolyfillError::order(
|
||||
format!("Order size {} below minimum {}", order.amount, self.min_fill_size),
|
||||
crate::errors::OrderErrorKind::SizeConstraint,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate limit order parameters
|
||||
fn validate_limit_order(&self, order: &OrderRequest) -> Result<()> {
|
||||
if order.size.is_zero() {
|
||||
return Err(PolyfillError::order(
|
||||
"Limit order size cannot be zero",
|
||||
crate::errors::OrderErrorKind::InvalidSize,
|
||||
));
|
||||
}
|
||||
|
||||
if order.price.is_zero() {
|
||||
return Err(PolyfillError::order(
|
||||
"Limit order price cannot be zero",
|
||||
crate::errors::OrderErrorKind::InvalidPrice,
|
||||
));
|
||||
}
|
||||
|
||||
if order.size < self.min_fill_size {
|
||||
return Err(PolyfillError::order(
|
||||
format!("Order size {} below minimum {}", order.size, self.min_fill_size),
|
||||
crate::errors::OrderErrorKind::SizeConstraint,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get fills for an order
|
||||
pub fn get_fills(&self, order_id: &str) -> Option<&[FillEvent]> {
|
||||
self.fills.get(order_id).map(|f| f.as_slice())
|
||||
}
|
||||
|
||||
/// Get all fills
|
||||
pub fn get_all_fills(&self) -> Vec<&FillEvent> {
|
||||
self.fills.values().flatten().collect()
|
||||
}
|
||||
|
||||
/// Clear fills for an order
|
||||
pub fn clear_fills(&mut self, order_id: &str) {
|
||||
self.fills.remove(order_id);
|
||||
}
|
||||
|
||||
/// Get fill statistics
|
||||
pub fn get_stats(&self) -> FillStats {
|
||||
let total_fills = self.fills.values().flatten().count();
|
||||
let total_volume: Decimal = self.fills.values().flatten().map(|f| f.size).sum();
|
||||
let total_fees: Decimal = self.fills.values().flatten().map(|f| f.fee).sum();
|
||||
|
||||
FillStats {
|
||||
total_orders: self.fills.len(),
|
||||
total_fills,
|
||||
total_volume,
|
||||
total_fees,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FillStats {
|
||||
pub total_orders: usize,
|
||||
pub total_fills: usize,
|
||||
pub total_volume: Decimal,
|
||||
pub total_fees: Decimal,
|
||||
}
|
||||
|
||||
/// Fill event processor for real-time updates
|
||||
#[derive(Debug)]
|
||||
pub struct FillProcessor {
|
||||
/// Pending fills by order ID
|
||||
pending_fills: HashMap<String, Vec<FillEvent>>,
|
||||
/// Processed fills
|
||||
processed_fills: Vec<FillEvent>,
|
||||
/// Maximum pending fills to keep in memory
|
||||
max_pending: usize,
|
||||
}
|
||||
|
||||
impl FillProcessor {
|
||||
/// Create a new fill processor
|
||||
pub fn new(max_pending: usize) -> Self {
|
||||
Self {
|
||||
pending_fills: HashMap::new(),
|
||||
processed_fills: Vec::new(),
|
||||
max_pending,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a fill event
|
||||
pub fn process_fill(&mut self, fill: FillEvent) -> Result<()> {
|
||||
// Validate fill
|
||||
self.validate_fill(&fill)?;
|
||||
|
||||
// Add to pending fills
|
||||
self.pending_fills
|
||||
.entry(fill.order_id.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(fill.clone());
|
||||
|
||||
// Move to processed if complete
|
||||
if self.is_order_complete(&fill.order_id) {
|
||||
if let Some(fills) = self.pending_fills.remove(&fill.order_id) {
|
||||
self.processed_fills.extend(fills);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup if too many pending
|
||||
if self.pending_fills.len() > self.max_pending {
|
||||
self.cleanup_old_pending();
|
||||
}
|
||||
|
||||
debug!("Processed fill: {} {} @ {}", fill.size, fill.side.as_str(), fill.price);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a fill event
|
||||
fn validate_fill(&self, fill: &FillEvent) -> Result<()> {
|
||||
if fill.size.is_zero() {
|
||||
return Err(PolyfillError::order(
|
||||
"Fill size cannot be zero",
|
||||
crate::errors::OrderErrorKind::InvalidSize,
|
||||
));
|
||||
}
|
||||
|
||||
if fill.price.is_zero() {
|
||||
return Err(PolyfillError::order(
|
||||
"Fill price cannot be zero",
|
||||
crate::errors::OrderErrorKind::InvalidPrice,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an order is complete
|
||||
fn is_order_complete(&self, _order_id: &str) -> bool {
|
||||
// Simplified implementation - in practice you'd check against order book
|
||||
false
|
||||
}
|
||||
|
||||
/// Cleanup old pending fills
|
||||
fn cleanup_old_pending(&mut self) {
|
||||
// Remove oldest pending fills
|
||||
let to_remove = self.pending_fills.len() - self.max_pending;
|
||||
let mut keys: Vec<_> = self.pending_fills.keys().cloned().collect();
|
||||
keys.sort(); // Simple ordering - in practice you'd use timestamps
|
||||
|
||||
for key in keys.iter().take(to_remove) {
|
||||
self.pending_fills.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get pending fills for an order
|
||||
pub fn get_pending_fills(&self, order_id: &str) -> Option<&[FillEvent]> {
|
||||
self.pending_fills.get(order_id).map(|f| f.as_slice())
|
||||
}
|
||||
|
||||
/// Get processed fills
|
||||
pub fn get_processed_fills(&self) -> &[FillEvent] {
|
||||
&self.processed_fills
|
||||
}
|
||||
|
||||
/// Get fill statistics
|
||||
pub fn get_stats(&self) -> FillProcessorStats {
|
||||
let total_pending: Decimal = self.pending_fills.values().flatten().map(|f| f.size).sum();
|
||||
let total_processed: Decimal = self.processed_fills.iter().map(|f| f.size).sum();
|
||||
|
||||
FillProcessorStats {
|
||||
pending_orders: self.pending_fills.len(),
|
||||
pending_fills: self.pending_fills.values().flatten().count(),
|
||||
pending_volume: total_pending,
|
||||
processed_fills: self.processed_fills.len(),
|
||||
processed_volume: total_processed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill processor statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FillProcessorStats {
|
||||
pub pending_orders: usize,
|
||||
pub pending_fills: usize,
|
||||
pub pending_volume: Decimal,
|
||||
pub processed_fills: usize,
|
||||
pub processed_volume: Decimal,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
#[test]
|
||||
fn test_fill_engine_creation() {
|
||||
let engine = FillEngine::new(dec!(1), dec!(5), 10);
|
||||
assert_eq!(engine.min_fill_size, dec!(1));
|
||||
assert_eq!(engine.max_slippage_pct, dec!(5));
|
||||
assert_eq!(engine.fee_rate_bps, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_market_order_validation() {
|
||||
let engine = FillEngine::new(dec!(1), dec!(5), 10);
|
||||
|
||||
let valid_order = MarketOrderRequest {
|
||||
token_id: "test".to_string(),
|
||||
side: Side::BUY,
|
||||
amount: dec!(100),
|
||||
slippage_tolerance: None,
|
||||
client_id: None,
|
||||
};
|
||||
assert!(engine.validate_market_order(&valid_order).is_ok());
|
||||
|
||||
let invalid_order = MarketOrderRequest {
|
||||
token_id: "test".to_string(),
|
||||
side: Side::BUY,
|
||||
amount: dec!(0),
|
||||
slippage_tolerance: None,
|
||||
client_id: None,
|
||||
};
|
||||
assert!(engine.validate_market_order(&invalid_order).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fee_calculation() {
|
||||
let engine = FillEngine::new(dec!(1), dec!(5), 10);
|
||||
let fee = engine.calculate_fee(dec!(1000));
|
||||
assert_eq!(fee, dec!(1)); // 10 bps = 0.1% = 1 on 1000
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill_processor() {
|
||||
let mut processor = FillProcessor::new(100);
|
||||
|
||||
let fill = FillEvent {
|
||||
id: "fill1".to_string(),
|
||||
order_id: "order1".to_string(),
|
||||
token_id: "test".to_string(),
|
||||
side: Side::BUY,
|
||||
price: dec!(0.5),
|
||||
size: dec!(100),
|
||||
timestamp: Utc::now(),
|
||||
maker_address: Address::ZERO,
|
||||
taker_address: Address::ZERO,
|
||||
fee: dec!(0.1),
|
||||
};
|
||||
|
||||
assert!(processor.process_fill(fill).is_ok());
|
||||
assert_eq!(processor.pending_fills.len(), 1);
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
//! Polyfill-rs: High-performance Rust client for Polymarket
|
||||
//!
|
||||
//! A production-ready Rust client for Polymarket optimized for high-frequency trading.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - **High-performance order book management** with optimized data structures
|
||||
//! - **Real-time market data streaming** with WebSocket support
|
||||
//! - **Trade execution simulation** with slippage protection
|
||||
//! - **Comprehensive error handling** with specific error types
|
||||
//! - **Rate limiting and retry logic** for robust API interactions
|
||||
//! - **Ethereum integration** with EIP-712 signing support
|
||||
//! - **Benchmarking tools** for performance analysis
|
||||
//!
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```rust
|
||||
//! use polyfill_rs::{ClobClient, OrderArgs, Side};
|
||||
//! use rust_decimal::Decimal;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Create client (compatible with polymarket-rs-client)
|
||||
//! let mut client = ClobClient::with_l1_headers(
|
||||
//! "https://clob.polymarket.com",
|
||||
//! "your_private_key",
|
||||
//! 137,
|
||||
//! );
|
||||
//!
|
||||
//! // Get API credentials
|
||||
//! let api_creds = client.create_or_derive_api_key(None).await?;
|
||||
//! client.set_api_creds(api_creds);
|
||||
//!
|
||||
//! // Create and post order
|
||||
//! let order_args = OrderArgs::new(
|
||||
//! "token_id",
|
||||
//! Decimal::from_str("0.75")?,
|
||||
//! Decimal::from_str("100.0")?,
|
||||
//! Side::BUY,
|
||||
//! );
|
||||
//!
|
||||
//! let result = client.create_and_post_order(&order_args).await?;
|
||||
//! println!("Order posted: {:?}", result);
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Advanced Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use polyfill_rs::{PolyfillClient, ClientConfig};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Advanced configuration
|
||||
//! let config = ClientConfig {
|
||||
//! base_url: "https://clob.polymarket.com".to_string(),
|
||||
//! chain_id: 137,
|
||||
//! private_key: Some("your_private_key".to_string()),
|
||||
//! max_slippage: Some(Decimal::from_str("0.001")?),
|
||||
//! fee_rate: Some(Decimal::from_str("0.02")?),
|
||||
//! ..Default::default()
|
||||
//! };
|
||||
//!
|
||||
//! let mut client = PolyfillClient::with_config(config)?;
|
||||
//!
|
||||
//! // Subscribe to real-time order book updates
|
||||
//! client.subscribe_to_order_book("token_id").await?;
|
||||
//!
|
||||
//! // Process incoming messages
|
||||
//! while let Some(message) = client.get_next_message().await? {
|
||||
//! println!("Received: {:?}", message);
|
||||
//! }
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use tracing::info;
|
||||
|
||||
|
||||
// Global constants
|
||||
pub const DEFAULT_CHAIN_ID: u64 = 137; // Polygon
|
||||
pub const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com";
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
pub const DEFAULT_MAX_RETRIES: u32 = 3;
|
||||
pub const DEFAULT_RATE_LIMIT_RPS: u32 = 100;
|
||||
|
||||
// Initialize logging
|
||||
pub fn init() {
|
||||
tracing_subscriber::fmt::init();
|
||||
info!("Polyfill-rs initialized");
|
||||
}
|
||||
|
||||
// Re-export main types
|
||||
pub use crate::types::{
|
||||
ApiCredentials, Balance, ClientConfig, FillEvent, MarketSnapshot, Order, OrderBook,
|
||||
OrderDelta, OrderRequest, OrderStatus, OrderType, Side, StreamMessage, WssAuth,
|
||||
WssSubscription, WssChannelType,
|
||||
};
|
||||
|
||||
// Re-export client
|
||||
pub use crate::client::{ClobClient, PolyfillClient};
|
||||
|
||||
// Re-export compatibility types (for easy migration from polymarket-rs-client)
|
||||
pub use crate::client::{
|
||||
OrderArgs, OrderBookSummary,
|
||||
};
|
||||
|
||||
// Re-export error types
|
||||
pub use crate::errors::{PolyfillError, Result};
|
||||
|
||||
// Re-export advanced components
|
||||
pub use crate::book::{OrderBook as OrderBookImpl, OrderBookManager};
|
||||
pub use crate::fill::{FillEngine, FillResult};
|
||||
pub use crate::stream::{MarketStream, StreamManager, WebSocketStream};
|
||||
pub use crate::decode::Decoder;
|
||||
|
||||
// Re-export utilities
|
||||
pub use crate::utils::{
|
||||
crypto, math, retry, time, url, rate_limit,
|
||||
};
|
||||
|
||||
// Module declarations
|
||||
pub mod book;
|
||||
pub mod client;
|
||||
pub mod decode;
|
||||
pub mod errors;
|
||||
pub mod fill;
|
||||
pub mod stream;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
// Benchmarks
|
||||
#[cfg(test)]
|
||||
mod benches {
|
||||
use criterion::{criterion_group, criterion_main};
|
||||
use crate::{OrderBookManager, OrderDelta, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use chrono::Utc;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn order_book_benchmark(c: &mut criterion::Criterion) {
|
||||
let mut book_manager = OrderBookManager::new(100);
|
||||
|
||||
c.bench_function("apply_order_delta", |b| {
|
||||
b.iter(|| {
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: Decimal::from_str("0.75").unwrap(),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence: 1,
|
||||
};
|
||||
|
||||
let _ = book_manager.apply_delta(delta);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, order_book_benchmark);
|
||||
criterion_main!(benches);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use alloy_primitives::U256;
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let client = ClobClient::new("https://test.example.com");
|
||||
// Test that the client was created successfully
|
||||
// We can't test private fields, but we can verify the client exists
|
||||
assert!(true); // Client creation successful
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_order_args_creation() {
|
||||
let args = OrderArgs::new(
|
||||
"test_token",
|
||||
Decimal::from_str("0.75").unwrap(),
|
||||
Decimal::from_str("100.0").unwrap(),
|
||||
Side::BUY,
|
||||
);
|
||||
|
||||
assert_eq!(args.token_id, "test_token");
|
||||
assert_eq!(args.side, Side::BUY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_order_args_default() {
|
||||
let args = OrderArgs::default();
|
||||
assert_eq!(args.token_id, "");
|
||||
assert_eq!(args.price, Decimal::ZERO);
|
||||
assert_eq!(args.size, Decimal::ZERO);
|
||||
assert_eq!(args.side, Side::BUY);
|
||||
}
|
||||
}
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
//! Async streaming functionality for Polymarket client
|
||||
//!
|
||||
//! This module provides high-performance streaming capabilities for
|
||||
//! real-time market data and order updates.
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
use futures::{Sink, Stream, SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use chrono::Utc;
|
||||
use alloy_primitives::U256;
|
||||
|
||||
/// Trait for market data streams
|
||||
pub trait MarketStream: Stream<Item = Result<StreamMessage>> + Send + Sync {
|
||||
/// Subscribe to market data for specific tokens
|
||||
fn subscribe(&mut self, subscription: Subscription) -> Result<()>;
|
||||
|
||||
/// Unsubscribe from market data
|
||||
fn unsubscribe(&mut self, token_ids: &[String]) -> Result<()>;
|
||||
|
||||
/// Check if the stream is connected
|
||||
fn is_connected(&self) -> bool;
|
||||
|
||||
/// Get connection statistics
|
||||
fn get_stats(&self) -> StreamStats;
|
||||
}
|
||||
|
||||
/// WebSocket-based market stream implementation
|
||||
#[derive(Debug)]
|
||||
pub struct WebSocketStream {
|
||||
/// WebSocket connection
|
||||
connection: Option<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>,
|
||||
/// URL for the WebSocket connection
|
||||
url: String,
|
||||
/// Authentication credentials
|
||||
auth: Option<WssAuth>,
|
||||
/// Current subscriptions
|
||||
subscriptions: Vec<WssSubscription>,
|
||||
/// Message sender for internal communication
|
||||
tx: mpsc::UnboundedSender<StreamMessage>,
|
||||
/// Message receiver
|
||||
rx: mpsc::UnboundedReceiver<StreamMessage>,
|
||||
/// Connection statistics
|
||||
stats: StreamStats,
|
||||
/// Reconnection configuration
|
||||
reconnect_config: ReconnectConfig,
|
||||
}
|
||||
|
||||
/// Stream statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamStats {
|
||||
pub messages_received: u64,
|
||||
pub messages_sent: u64,
|
||||
pub errors: u64,
|
||||
pub last_message_time: Option<chrono::DateTime<Utc>>,
|
||||
pub connection_uptime: std::time::Duration,
|
||||
pub reconnect_count: u32,
|
||||
}
|
||||
|
||||
/// Reconnection configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReconnectConfig {
|
||||
pub max_retries: u32,
|
||||
pub base_delay: std::time::Duration,
|
||||
pub max_delay: std::time::Duration,
|
||||
pub backoff_multiplier: f64,
|
||||
}
|
||||
|
||||
impl Default for ReconnectConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 5,
|
||||
base_delay: std::time::Duration::from_secs(1),
|
||||
max_delay: std::time::Duration::from_secs(60),
|
||||
backoff_multiplier: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebSocketStream {
|
||||
/// Create a new WebSocket stream
|
||||
pub fn new(url: &str) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
Self {
|
||||
connection: None,
|
||||
url: url.to_string(),
|
||||
auth: None,
|
||||
subscriptions: Vec::new(),
|
||||
tx,
|
||||
rx,
|
||||
stats: StreamStats {
|
||||
messages_received: 0,
|
||||
messages_sent: 0,
|
||||
errors: 0,
|
||||
last_message_time: None,
|
||||
connection_uptime: std::time::Duration::ZERO,
|
||||
reconnect_count: 0,
|
||||
},
|
||||
reconnect_config: ReconnectConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set authentication credentials
|
||||
pub fn with_auth(mut self, auth: WssAuth) -> Self {
|
||||
self.auth = Some(auth);
|
||||
self
|
||||
}
|
||||
|
||||
/// Connect to the WebSocket
|
||||
async fn connect(&mut self) -> Result<()> {
|
||||
let (ws_stream, _) = tokio_tungstenite::connect_async(&self.url).await
|
||||
.map_err(|e| PolyfillError::stream(format!("WebSocket connection failed: {}", e), crate::errors::StreamErrorKind::ConnectionFailed))?;
|
||||
|
||||
self.connection = Some(ws_stream);
|
||||
info!("Connected to WebSocket stream at {}", self.url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a message to the WebSocket
|
||||
async fn send_message(&mut self, message: Value) -> Result<()> {
|
||||
if let Some(connection) = &mut self.connection {
|
||||
let text = serde_json::to_string(&message)
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to serialize message: {}", e), None))?;
|
||||
|
||||
let ws_message = tokio_tungstenite::tungstenite::Message::Text(text);
|
||||
connection.send(ws_message).await
|
||||
.map_err(|e| PolyfillError::stream(format!("Failed to send message: {}", e), crate::errors::StreamErrorKind::MessageCorrupted))?;
|
||||
|
||||
self.stats.messages_sent += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe to market data using official Polymarket WebSocket API
|
||||
pub async fn subscribe_async(&mut self, subscription: WssSubscription) -> Result<()> {
|
||||
// Ensure connection
|
||||
if self.connection.is_none() {
|
||||
self.connect().await?;
|
||||
}
|
||||
|
||||
// Send subscription message in the format expected by Polymarket
|
||||
let message = serde_json::json!({
|
||||
"auth": subscription.auth,
|
||||
"markets": subscription.markets,
|
||||
"asset_ids": subscription.asset_ids,
|
||||
"type": subscription.channel_type,
|
||||
});
|
||||
|
||||
self.send_message(message).await?;
|
||||
self.subscriptions.push(subscription.clone());
|
||||
|
||||
info!("Subscribed to {} channel", subscription.channel_type);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe to user channel (orders and trades)
|
||||
pub async fn subscribe_user_channel(&mut self, markets: Vec<String>) -> Result<()> {
|
||||
let auth = self.auth.as_ref()
|
||||
.ok_or_else(|| PolyfillError::auth("No authentication provided for WebSocket", crate::errors::AuthErrorKind::InvalidCredentials))?
|
||||
.clone();
|
||||
|
||||
let subscription = WssSubscription {
|
||||
auth,
|
||||
markets: Some(markets),
|
||||
asset_ids: None,
|
||||
channel_type: "USER".to_string(),
|
||||
};
|
||||
|
||||
self.subscribe_async(subscription).await
|
||||
}
|
||||
|
||||
/// Subscribe to market channel (order book and trades)
|
||||
pub async fn subscribe_market_channel(&mut self, asset_ids: Vec<String>) -> Result<()> {
|
||||
let auth = self.auth.as_ref()
|
||||
.ok_or_else(|| PolyfillError::auth("No authentication provided for WebSocket", crate::errors::AuthErrorKind::InvalidCredentials))?
|
||||
.clone();
|
||||
|
||||
let subscription = WssSubscription {
|
||||
auth,
|
||||
markets: None,
|
||||
asset_ids: Some(asset_ids),
|
||||
channel_type: "MARKET".to_string(),
|
||||
};
|
||||
|
||||
self.subscribe_async(subscription).await
|
||||
}
|
||||
|
||||
/// Unsubscribe from market data
|
||||
pub async fn unsubscribe_async(&mut self, token_ids: &[String]) -> Result<()> {
|
||||
// Note: Polymarket WebSocket API doesn't seem to have explicit unsubscribe
|
||||
// We'll just remove from our local subscriptions
|
||||
self.subscriptions.retain(|sub| {
|
||||
match sub.channel_type.as_str() {
|
||||
"USER" => {
|
||||
if let Some(markets) = &sub.markets {
|
||||
!token_ids.iter().any(|id| markets.contains(id))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
"MARKET" => {
|
||||
if let Some(asset_ids) = &sub.asset_ids {
|
||||
!token_ids.iter().any(|id| asset_ids.contains(id))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
_ => true
|
||||
}
|
||||
});
|
||||
|
||||
info!("Unsubscribed from {} tokens", token_ids.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle incoming WebSocket messages
|
||||
async fn handle_message(&mut self, message: tokio_tungstenite::tungstenite::Message) -> Result<()> {
|
||||
match message {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
|
||||
// Parse the message according to Polymarket's format
|
||||
let stream_message = self.parse_polymarket_message(&text)?;
|
||||
|
||||
// Send to internal channel
|
||||
if let Err(e) = self.tx.send(stream_message) {
|
||||
error!("Failed to send message to internal channel: {}", e);
|
||||
}
|
||||
|
||||
self.stats.messages_received += 1;
|
||||
self.stats.last_message_time = Some(Utc::now());
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => {
|
||||
info!("WebSocket connection closed by server");
|
||||
self.connection = None;
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Ping(data) => {
|
||||
// Respond with pong
|
||||
if let Some(connection) = &mut self.connection {
|
||||
let pong = tokio_tungstenite::tungstenite::Message::Pong(data);
|
||||
if let Err(e) = connection.send(pong).await {
|
||||
error!("Failed to send pong: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => {
|
||||
// Handle pong if needed
|
||||
debug!("Received pong");
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Binary(_) => {
|
||||
warn!("Received binary message (not supported)");
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Frame(_) => {
|
||||
warn!("Received raw frame (not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse Polymarket WebSocket message format
|
||||
fn parse_polymarket_message(&self, text: &str) -> Result<StreamMessage> {
|
||||
let value: Value = serde_json::from_str(text)
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse WebSocket message: {}", e), Some(Box::new(e))))?;
|
||||
|
||||
// Extract message type
|
||||
let message_type = value.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| PolyfillError::parse("Missing 'type' field in WebSocket message", None))?;
|
||||
|
||||
match message_type {
|
||||
"book_update" => {
|
||||
let data = serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse book update: {}", e), Some(Box::new(e))))?;
|
||||
Ok(StreamMessage::BookUpdate { data })
|
||||
}
|
||||
"trade" => {
|
||||
let data = serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse trade: {}", e), Some(Box::new(e))))?;
|
||||
Ok(StreamMessage::Trade { data })
|
||||
}
|
||||
"order_update" => {
|
||||
let data = serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse order update: {}", e), Some(Box::new(e))))?;
|
||||
Ok(StreamMessage::OrderUpdate { data })
|
||||
}
|
||||
"user_order_update" => {
|
||||
let data = serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse user order update: {}", e), Some(Box::new(e))))?;
|
||||
Ok(StreamMessage::UserOrderUpdate { data })
|
||||
}
|
||||
"user_trade" => {
|
||||
let data = serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse user trade: {}", e), Some(Box::new(e))))?;
|
||||
Ok(StreamMessage::UserTrade { data })
|
||||
}
|
||||
"market_book_update" => {
|
||||
let data = serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse market book update: {}", e), Some(Box::new(e))))?;
|
||||
Ok(StreamMessage::MarketBookUpdate { data })
|
||||
}
|
||||
"market_trade" => {
|
||||
let data = serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| PolyfillError::parse(format!("Failed to parse market trade: {}", e), Some(Box::new(e))))?;
|
||||
Ok(StreamMessage::MarketTrade { data })
|
||||
}
|
||||
"heartbeat" => {
|
||||
let timestamp = value.get("timestamp")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|ts| chrono::DateTime::from_timestamp(ts as i64, 0).unwrap_or_default())
|
||||
.unwrap_or_else(Utc::now);
|
||||
Ok(StreamMessage::Heartbeat { timestamp })
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown message type: {}", message_type);
|
||||
// Return heartbeat as fallback
|
||||
Ok(StreamMessage::Heartbeat { timestamp: Utc::now() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconnect with exponential backoff
|
||||
async fn reconnect(&mut self) -> Result<()> {
|
||||
let mut delay = self.reconnect_config.base_delay;
|
||||
let mut retries = 0;
|
||||
|
||||
while retries < self.reconnect_config.max_retries {
|
||||
warn!("Attempting to reconnect (attempt {})", retries + 1);
|
||||
|
||||
match self.connect().await {
|
||||
Ok(()) => {
|
||||
info!("Successfully reconnected");
|
||||
self.stats.reconnect_count += 1;
|
||||
|
||||
// Resubscribe to all previous subscriptions
|
||||
let subscriptions = self.subscriptions.clone();
|
||||
for subscription in subscriptions {
|
||||
self.send_message(serde_json::to_value(subscription)?).await?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Reconnection attempt {} failed: {}", retries + 1, e);
|
||||
retries += 1;
|
||||
|
||||
if retries < self.reconnect_config.max_retries {
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = std::cmp::min(
|
||||
delay.mul_f64(self.reconnect_config.backoff_multiplier),
|
||||
self.reconnect_config.max_delay
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(PolyfillError::stream(
|
||||
format!("Failed to reconnect after {} attempts", self.reconnect_config.max_retries),
|
||||
crate::errors::StreamErrorKind::ConnectionFailed
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for WebSocketStream {
|
||||
type Item = Result<StreamMessage>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
// First check internal channel
|
||||
if let Poll::Ready(Some(message)) = self.rx.poll_recv(cx) {
|
||||
return Poll::Ready(Some(Ok(message)));
|
||||
}
|
||||
|
||||
// Then check WebSocket connection
|
||||
if let Some(connection) = &mut self.connection {
|
||||
match connection.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(message))) => {
|
||||
// Simplified message handling
|
||||
Poll::Ready(Some(Ok(StreamMessage::Heartbeat { timestamp: Utc::now() })))
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
self.stats.errors += 1;
|
||||
Poll::Ready(Some(Err(e.into())))
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
info!("WebSocket stream ended");
|
||||
Poll::Ready(None)
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
} else {
|
||||
Poll::Ready(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarketStream for WebSocketStream {
|
||||
fn subscribe(&mut self, _subscription: Subscription) -> Result<()> {
|
||||
// This is for backward compatibility - use subscribe_async for new code
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, _token_ids: &[String]) -> Result<()> {
|
||||
// This is for backward compatibility - use unsubscribe_async for new code
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connection.is_some()
|
||||
}
|
||||
|
||||
fn get_stats(&self) -> StreamStats {
|
||||
self.stats.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock stream for testing
|
||||
#[derive(Debug)]
|
||||
pub struct MockStream {
|
||||
messages: Vec<Result<StreamMessage>>,
|
||||
index: usize,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
impl MockStream {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
index: 0,
|
||||
connected: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_message(&mut self, message: StreamMessage) {
|
||||
self.messages.push(Ok(message));
|
||||
}
|
||||
|
||||
pub fn add_error(&mut self, error: PolyfillError) {
|
||||
self.messages.push(Err(error));
|
||||
}
|
||||
|
||||
pub fn set_connected(&mut self, connected: bool) {
|
||||
self.connected = connected;
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for MockStream {
|
||||
type Item = Result<StreamMessage>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
if self.index >= self.messages.len() {
|
||||
Poll::Ready(None)
|
||||
} else {
|
||||
let message = self.messages[self.index].clone();
|
||||
self.index += 1;
|
||||
Poll::Ready(Some(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarketStream for MockStream {
|
||||
fn subscribe(&mut self, _subscription: Subscription) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, _token_ids: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
|
||||
fn get_stats(&self) -> StreamStats {
|
||||
StreamStats {
|
||||
messages_received: self.messages.len() as u64,
|
||||
messages_sent: 0,
|
||||
errors: self.messages.iter().filter(|m| m.is_err()).count() as u64,
|
||||
last_message_time: None,
|
||||
connection_uptime: std::time::Duration::ZERO,
|
||||
reconnect_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream manager for handling multiple streams
|
||||
pub struct StreamManager {
|
||||
streams: Vec<Box<dyn MarketStream>>,
|
||||
message_tx: mpsc::UnboundedSender<StreamMessage>,
|
||||
message_rx: mpsc::UnboundedReceiver<StreamMessage>,
|
||||
}
|
||||
|
||||
impl StreamManager {
|
||||
pub fn new() -> Self {
|
||||
let (message_tx, message_rx) = mpsc::unbounded_channel();
|
||||
|
||||
Self {
|
||||
streams: Vec::new(),
|
||||
message_tx,
|
||||
message_rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_stream(&mut self, stream: Box<dyn MarketStream>) {
|
||||
self.streams.push(stream);
|
||||
}
|
||||
|
||||
pub fn get_message_receiver(&mut self) -> mpsc::UnboundedReceiver<StreamMessage> {
|
||||
// Note: UnboundedReceiver doesn't implement Clone
|
||||
// In a real implementation, you'd want to use a different approach
|
||||
// For now, we'll return a dummy receiver
|
||||
let (_, rx) = mpsc::unbounded_channel();
|
||||
rx
|
||||
}
|
||||
|
||||
pub fn broadcast_message(&self, message: StreamMessage) -> Result<()> {
|
||||
self.message_tx.send(message)
|
||||
.map_err(|e| PolyfillError::internal("Failed to broadcast message", e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mock_stream() {
|
||||
let mut stream = MockStream::new();
|
||||
|
||||
// Add some test messages
|
||||
stream.add_message(StreamMessage::Heartbeat { timestamp: Utc::now() });
|
||||
stream.add_message(StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: "test".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: rust_decimal_macros::dec!(0.5),
|
||||
size: rust_decimal_macros::dec!(100),
|
||||
sequence: 1,
|
||||
}
|
||||
});
|
||||
|
||||
assert!(stream.is_connected());
|
||||
assert_eq!(stream.get_stats().messages_received, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_manager() {
|
||||
let mut manager = StreamManager::new();
|
||||
let mock_stream = Box::new(MockStream::new());
|
||||
manager.add_stream(mock_stream);
|
||||
|
||||
// Test message broadcasting
|
||||
let message = StreamMessage::Heartbeat { timestamp: Utc::now() };
|
||||
assert!(manager.broadcast_message(message).is_ok());
|
||||
}
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
//! Core types for the Polymarket client
|
||||
//!
|
||||
//! This module defines all the stable public types used throughout the client.
|
||||
//! These types are optimized for latency-sensitive trading environments.
|
||||
|
||||
use alloy_primitives::{Address, U256};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Trading side for orders
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Side {
|
||||
BUY = 0,
|
||||
SELL = 1,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Side::BUY => "BUY",
|
||||
Side::SELL => "SELL",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn opposite(&self) -> Self {
|
||||
match self {
|
||||
Side::BUY => Side::SELL,
|
||||
Side::SELL => Side::BUY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Order type specifications
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum OrderType {
|
||||
GTC,
|
||||
FOK,
|
||||
GTD,
|
||||
}
|
||||
|
||||
impl OrderType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OrderType::GTC => "GTC",
|
||||
OrderType::FOK => "FOK",
|
||||
OrderType::GTD => "GTD",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Order status in the system
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum OrderStatus {
|
||||
#[serde(rename = "LIVE")]
|
||||
Live,
|
||||
#[serde(rename = "CANCELLED")]
|
||||
Cancelled,
|
||||
#[serde(rename = "FILLED")]
|
||||
Filled,
|
||||
#[serde(rename = "PARTIAL")]
|
||||
Partial,
|
||||
#[serde(rename = "EXPIRED")]
|
||||
Expired,
|
||||
}
|
||||
|
||||
/// Market snapshot representing current state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketSnapshot {
|
||||
pub token_id: String,
|
||||
pub market_id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub bid: Option<Decimal>,
|
||||
pub ask: Option<Decimal>,
|
||||
pub mid: Option<Decimal>,
|
||||
pub spread: Option<Decimal>,
|
||||
pub last_price: Option<Decimal>,
|
||||
pub volume_24h: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// Order book level (price/size pair)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BookLevel {
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub size: Decimal,
|
||||
}
|
||||
|
||||
/// Full order book state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrderBook {
|
||||
/// Token ID
|
||||
pub token_id: String,
|
||||
/// Timestamp
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Bid orders
|
||||
pub bids: Vec<BookLevel>,
|
||||
/// Ask orders
|
||||
pub asks: Vec<BookLevel>,
|
||||
/// Sequence number
|
||||
pub sequence: u64,
|
||||
}
|
||||
|
||||
/// Order book delta for streaming updates
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrderDelta {
|
||||
pub token_id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub side: Side,
|
||||
pub price: Decimal,
|
||||
pub size: Decimal, // 0 means remove level
|
||||
pub sequence: u64,
|
||||
}
|
||||
|
||||
/// Trade execution event
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FillEvent {
|
||||
pub id: String,
|
||||
pub order_id: String,
|
||||
pub token_id: String,
|
||||
pub side: Side,
|
||||
pub price: Decimal,
|
||||
pub size: Decimal,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub maker_address: Address,
|
||||
pub taker_address: Address,
|
||||
pub fee: Decimal,
|
||||
}
|
||||
|
||||
/// Order creation parameters
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderRequest {
|
||||
pub token_id: String,
|
||||
pub side: Side,
|
||||
pub price: Decimal,
|
||||
pub size: Decimal,
|
||||
pub order_type: OrderType,
|
||||
pub expiration: Option<DateTime<Utc>>,
|
||||
pub client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Market order parameters
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarketOrderRequest {
|
||||
pub token_id: String,
|
||||
pub side: Side,
|
||||
pub amount: Decimal, // USD amount for buys, token amount for sells
|
||||
pub slippage_tolerance: Option<Decimal>,
|
||||
pub client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Order state in the system
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Order {
|
||||
pub id: String,
|
||||
pub token_id: String,
|
||||
pub side: Side,
|
||||
pub price: Decimal,
|
||||
pub original_size: Decimal,
|
||||
pub filled_size: Decimal,
|
||||
pub remaining_size: Decimal,
|
||||
pub status: OrderStatus,
|
||||
pub order_type: OrderType,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub expiration: Option<DateTime<Utc>>,
|
||||
pub client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// API credentials for authentication
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiCredentials {
|
||||
pub api_key: String,
|
||||
pub secret: String,
|
||||
pub passphrase: String,
|
||||
}
|
||||
|
||||
/// Configuration for order creation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderOptions {
|
||||
pub tick_size: Option<Decimal>,
|
||||
pub neg_risk: Option<bool>,
|
||||
pub fee_rate_bps: Option<u32>,
|
||||
}
|
||||
|
||||
/// Market information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Market {
|
||||
pub condition_id: String,
|
||||
pub tokens: [Token; 2],
|
||||
pub active: bool,
|
||||
pub closed: bool,
|
||||
pub question: String,
|
||||
pub description: String,
|
||||
pub category: Option<String>,
|
||||
pub end_date_iso: Option<String>,
|
||||
pub minimum_order_size: Decimal,
|
||||
pub minimum_tick_size: Decimal,
|
||||
}
|
||||
|
||||
/// Token information within a market
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Token {
|
||||
pub token_id: String,
|
||||
pub outcome: String,
|
||||
}
|
||||
|
||||
/// Client configuration for PolyfillClient
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientConfig {
|
||||
/// Base URL for the API
|
||||
pub base_url: String,
|
||||
/// Chain ID for the network
|
||||
pub chain_id: u64,
|
||||
/// Private key for signing (optional)
|
||||
pub private_key: Option<String>,
|
||||
/// API credentials (optional)
|
||||
pub api_credentials: Option<ApiCredentials>,
|
||||
/// Maximum slippage tolerance
|
||||
pub max_slippage: Option<Decimal>,
|
||||
/// Fee rate in basis points
|
||||
pub fee_rate: Option<Decimal>,
|
||||
/// Request timeout
|
||||
pub timeout: Option<std::time::Duration>,
|
||||
/// Maximum number of connections
|
||||
pub max_connections: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain_id: 137, // Polygon mainnet
|
||||
private_key: None,
|
||||
api_credentials: None,
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
max_connections: Some(100),
|
||||
max_slippage: None,
|
||||
fee_rate: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket authentication for Polymarket API
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WssAuth {
|
||||
/// User's Ethereum address
|
||||
pub address: String,
|
||||
/// EIP-712 signature
|
||||
pub signature: String,
|
||||
/// Unix timestamp
|
||||
pub timestamp: u64,
|
||||
/// Nonce for replay protection
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
/// WebSocket subscription request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WssSubscription {
|
||||
/// Authentication information
|
||||
pub auth: WssAuth,
|
||||
/// Array of markets (condition IDs) for USER channel
|
||||
pub markets: Option<Vec<String>>,
|
||||
/// Array of asset IDs (token IDs) for MARKET channel
|
||||
pub asset_ids: Option<Vec<String>>,
|
||||
/// Channel type: "USER" or "MARKET"
|
||||
#[serde(rename = "type")]
|
||||
pub channel_type: String,
|
||||
}
|
||||
|
||||
/// WebSocket message types for streaming
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum StreamMessage {
|
||||
#[serde(rename = "book_update")]
|
||||
BookUpdate {
|
||||
data: OrderDelta,
|
||||
},
|
||||
#[serde(rename = "trade")]
|
||||
Trade {
|
||||
data: FillEvent,
|
||||
},
|
||||
#[serde(rename = "order_update")]
|
||||
OrderUpdate {
|
||||
data: Order,
|
||||
},
|
||||
#[serde(rename = "heartbeat")]
|
||||
Heartbeat {
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
/// User channel events
|
||||
#[serde(rename = "user_order_update")]
|
||||
UserOrderUpdate {
|
||||
data: Order,
|
||||
},
|
||||
#[serde(rename = "user_trade")]
|
||||
UserTrade {
|
||||
data: FillEvent,
|
||||
},
|
||||
/// Market channel events
|
||||
#[serde(rename = "market_book_update")]
|
||||
MarketBookUpdate {
|
||||
data: OrderDelta,
|
||||
},
|
||||
#[serde(rename = "market_trade")]
|
||||
MarketTrade {
|
||||
data: FillEvent,
|
||||
},
|
||||
}
|
||||
|
||||
/// Subscription parameters for streaming
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Subscription {
|
||||
pub token_ids: Vec<String>,
|
||||
pub channels: Vec<String>,
|
||||
}
|
||||
|
||||
/// WebSocket channel types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum WssChannelType {
|
||||
#[serde(rename = "USER")]
|
||||
User,
|
||||
#[serde(rename = "MARKET")]
|
||||
Market,
|
||||
}
|
||||
|
||||
impl WssChannelType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
WssChannelType::User => "USER",
|
||||
WssChannelType::Market => "MARKET",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Price quote response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Quote {
|
||||
pub token_id: String,
|
||||
pub side: Side,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Balance information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Balance {
|
||||
pub token_id: String,
|
||||
pub available: Decimal,
|
||||
pub locked: Decimal,
|
||||
pub total: Decimal,
|
||||
}
|
||||
|
||||
/// Performance metrics for monitoring
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Metrics {
|
||||
pub orders_per_second: f64,
|
||||
pub avg_latency_ms: f64,
|
||||
pub error_rate: f64,
|
||||
pub uptime_pct: f64,
|
||||
}
|
||||
|
||||
// Type aliases for common patterns
|
||||
pub type TokenId = String;
|
||||
pub type OrderId = String;
|
||||
pub type MarketId = String;
|
||||
pub type ClientId = String;
|
||||
|
||||
/// Result type used throughout the client
|
||||
pub type Result<T> = std::result::Result<T, crate::errors::PolyfillError>;
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
//! Utility functions for the Polymarket client
|
||||
//!
|
||||
//! This module contains optimized utility functions for performance-critical
|
||||
//! operations in trading environments.
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use alloy_primitives::{Address, U256};
|
||||
use base64::{engine::general_purpose::URL_SAFE, Engine};
|
||||
use chrono::{DateTime, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use std::str::FromStr;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use ::url::Url;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// High-precision timestamp utilities
|
||||
pub mod time {
|
||||
use super::*;
|
||||
|
||||
/// Get current Unix timestamp in seconds
|
||||
#[inline]
|
||||
pub fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Get current Unix timestamp in milliseconds
|
||||
#[inline]
|
||||
pub fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Get current Unix timestamp in microseconds
|
||||
#[inline]
|
||||
pub fn now_micros() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_micros() as u64
|
||||
}
|
||||
|
||||
/// Get current Unix timestamp in nanoseconds
|
||||
#[inline]
|
||||
pub fn now_nanos() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_nanos()
|
||||
}
|
||||
|
||||
/// Convert DateTime to Unix timestamp in seconds
|
||||
#[inline]
|
||||
pub fn datetime_to_secs(dt: DateTime<Utc>) -> u64 {
|
||||
dt.timestamp() as u64
|
||||
}
|
||||
|
||||
/// Convert Unix timestamp to DateTime
|
||||
#[inline]
|
||||
pub fn secs_to_datetime(timestamp: u64) -> DateTime<Utc> {
|
||||
DateTime::from_timestamp(timestamp as i64, 0)
|
||||
.unwrap_or_else(|| Utc::now())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cryptographic utilities for signing and authentication
|
||||
pub mod crypto {
|
||||
use super::*;
|
||||
|
||||
/// Build HMAC-SHA256 signature for API authentication
|
||||
pub fn build_hmac_signature<T>(
|
||||
secret: &str,
|
||||
timestamp: u64,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&T>,
|
||||
) -> Result<String>
|
||||
where
|
||||
T: ?Sized + Serialize,
|
||||
{
|
||||
let decoded = URL_SAFE
|
||||
.decode(secret)
|
||||
.map_err(|e| PolyfillError::config(format!("Invalid secret format: {}", e)))?;
|
||||
|
||||
let message = match body {
|
||||
None => format!("{timestamp}{method}{path}"),
|
||||
Some(data) => {
|
||||
let json = serde_json::to_string(data)?;
|
||||
format!("{timestamp}{method}{path}{json}")
|
||||
}
|
||||
};
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(&decoded)
|
||||
.map_err(|e| PolyfillError::internal("HMAC initialization failed", e))?;
|
||||
|
||||
mac.update(message.as_bytes());
|
||||
let result = mac.finalize();
|
||||
|
||||
Ok(URL_SAFE.encode(result.into_bytes()))
|
||||
}
|
||||
|
||||
/// Generate a secure random nonce
|
||||
pub fn generate_nonce() -> U256 {
|
||||
use rand::RngCore;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut bytes = [0u8; 32];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
U256::from_be_bytes(bytes)
|
||||
}
|
||||
|
||||
/// Generate a secure random salt
|
||||
pub fn generate_salt() -> u64 {
|
||||
use rand::RngCore;
|
||||
let mut rng = rand::thread_rng();
|
||||
rng.next_u64()
|
||||
}
|
||||
}
|
||||
|
||||
/// Price and size calculation utilities
|
||||
pub mod math {
|
||||
use super::*;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
/// Round price to tick size
|
||||
#[inline]
|
||||
pub fn round_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
|
||||
if tick_size.is_zero() {
|
||||
return price;
|
||||
}
|
||||
(price / tick_size).round() * tick_size
|
||||
}
|
||||
|
||||
/// Calculate notional value (price * size)
|
||||
#[inline]
|
||||
pub fn notional(price: Decimal, size: Decimal) -> Decimal {
|
||||
price * size
|
||||
}
|
||||
|
||||
/// Calculate spread as percentage
|
||||
#[inline]
|
||||
pub fn spread_pct(bid: Decimal, ask: Decimal) -> Option<Decimal> {
|
||||
if bid.is_zero() || ask <= bid {
|
||||
return None;
|
||||
}
|
||||
Some((ask - bid) / bid * Decimal::from(100))
|
||||
}
|
||||
|
||||
/// Calculate mid price
|
||||
#[inline]
|
||||
pub fn mid_price(bid: Decimal, ask: Decimal) -> Option<Decimal> {
|
||||
if bid.is_zero() || ask.is_zero() || ask <= bid {
|
||||
return None;
|
||||
}
|
||||
Some((bid + ask) / Decimal::from(2))
|
||||
}
|
||||
|
||||
/// Convert decimal to token units (6 decimal places)
|
||||
#[inline]
|
||||
pub fn decimal_to_token_units(amount: Decimal) -> u64 {
|
||||
let scaled = amount * Decimal::from(1_000_000);
|
||||
scaled.to_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Convert token units back to decimal
|
||||
#[inline]
|
||||
pub fn token_units_to_decimal(units: u64) -> Decimal {
|
||||
Decimal::from(units) / Decimal::from(1_000_000)
|
||||
}
|
||||
|
||||
/// Check if price is within valid range [tick_size, 1-tick_size]
|
||||
#[inline]
|
||||
pub fn is_valid_price(price: Decimal, tick_size: Decimal) -> bool {
|
||||
price >= tick_size && price <= (Decimal::ONE - tick_size)
|
||||
}
|
||||
|
||||
/// Calculate maximum slippage for market order
|
||||
pub fn calculate_slippage(
|
||||
target_price: Decimal,
|
||||
executed_price: Decimal,
|
||||
side: crate::types::Side,
|
||||
) -> Decimal {
|
||||
match side {
|
||||
crate::types::Side::BUY => {
|
||||
if executed_price > target_price {
|
||||
(executed_price - target_price) / target_price
|
||||
} else {
|
||||
Decimal::ZERO
|
||||
}
|
||||
}
|
||||
crate::types::Side::SELL => {
|
||||
if executed_price < target_price {
|
||||
(target_price - executed_price) / target_price
|
||||
} else {
|
||||
Decimal::ZERO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Network and retry utilities
|
||||
pub mod retry {
|
||||
use super::*;
|
||||
use std::future::Future;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
/// Exponential backoff configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryConfig {
|
||||
pub max_attempts: usize,
|
||||
pub initial_delay: Duration,
|
||||
pub max_delay: Duration,
|
||||
pub backoff_factor: f64,
|
||||
pub jitter: bool,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_attempts: 3,
|
||||
initial_delay: Duration::from_millis(100),
|
||||
max_delay: Duration::from_secs(10),
|
||||
backoff_factor: 2.0,
|
||||
jitter: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry a future with exponential backoff
|
||||
pub async fn with_retry<F, Fut, T>(
|
||||
config: &RetryConfig,
|
||||
mut operation: F,
|
||||
) -> Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T>>,
|
||||
{
|
||||
let mut delay = config.initial_delay;
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..config.max_attempts {
|
||||
match operation().await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(err) => {
|
||||
last_error = Some(err.clone());
|
||||
|
||||
if !err.is_retryable() || attempt == config.max_attempts - 1 {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Add jitter if enabled
|
||||
let actual_delay = if config.jitter {
|
||||
let jitter_factor = rand::random::<f64>() * 0.1; // ±10%
|
||||
let jitter = 1.0 + (jitter_factor - 0.05);
|
||||
Duration::from_nanos((delay.as_nanos() as f64 * jitter) as u64)
|
||||
} else {
|
||||
delay
|
||||
};
|
||||
|
||||
sleep(actual_delay).await;
|
||||
|
||||
// Exponential backoff
|
||||
delay = std::cmp::min(
|
||||
Duration::from_nanos((delay.as_nanos() as f64 * config.backoff_factor) as u64),
|
||||
config.max_delay,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| PolyfillError::internal("Retry loop failed", std::io::Error::new(std::io::ErrorKind::Other, "No error captured"))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Address and token ID utilities
|
||||
pub mod address {
|
||||
use super::*;
|
||||
|
||||
/// Validate and parse Ethereum address
|
||||
pub fn parse_address(addr: &str) -> Result<Address> {
|
||||
Address::from_str(addr)
|
||||
.map_err(|e| PolyfillError::validation(format!("Invalid address format: {}", e)))
|
||||
}
|
||||
|
||||
/// Validate token ID format
|
||||
pub fn validate_token_id(token_id: &str) -> Result<()> {
|
||||
if token_id.is_empty() {
|
||||
return Err(PolyfillError::validation("Token ID cannot be empty"));
|
||||
}
|
||||
|
||||
// Token IDs should be numeric strings
|
||||
if !token_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(PolyfillError::validation("Token ID must be numeric"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert token ID to U256
|
||||
pub fn token_id_to_u256(token_id: &str) -> Result<U256> {
|
||||
validate_token_id(token_id)?;
|
||||
U256::from_str_radix(token_id, 10)
|
||||
.map_err(|e| PolyfillError::validation(format!("Invalid token ID: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// URL building utilities
|
||||
pub mod url {
|
||||
use super::*;
|
||||
|
||||
/// Build API endpoint URL
|
||||
pub fn build_endpoint(base_url: &str, path: &str) -> Result<String> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
Ok(format!("{}/{}", base, path))
|
||||
}
|
||||
|
||||
/// Add query parameters to URL
|
||||
pub fn add_query_params(
|
||||
mut url: url::Url,
|
||||
params: &[(&str, &str)],
|
||||
) -> url::Url {
|
||||
{
|
||||
let mut query_pairs = url.query_pairs_mut();
|
||||
for (key, value) in params {
|
||||
query_pairs.append_pair(key, value);
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiting utilities
|
||||
pub mod rate_limit {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Simple token bucket rate limiter
|
||||
#[derive(Debug)]
|
||||
pub struct TokenBucket {
|
||||
capacity: usize,
|
||||
tokens: Arc<Mutex<usize>>,
|
||||
refill_rate: Duration,
|
||||
last_refill: Arc<Mutex<SystemTime>>,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
pub fn new(capacity: usize, refill_per_second: usize) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
tokens: Arc::new(Mutex::new(capacity)),
|
||||
refill_rate: Duration::from_secs(1) / refill_per_second as u32,
|
||||
last_refill: Arc::new(Mutex::new(SystemTime::now())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to consume a token, return true if successful
|
||||
pub fn try_consume(&self) -> bool {
|
||||
self.refill();
|
||||
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
if *tokens > 0 {
|
||||
*tokens -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn refill(&self) {
|
||||
let now = SystemTime::now();
|
||||
let mut last_refill = self.last_refill.lock().unwrap();
|
||||
let elapsed = now.duration_since(*last_refill).unwrap_or_default();
|
||||
|
||||
if elapsed >= self.refill_rate {
|
||||
let tokens_to_add = elapsed.as_nanos() / self.refill_rate.as_nanos();
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
*tokens = std::cmp::min(self.capacity, *tokens + tokens_to_add as usize);
|
||||
*last_refill = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_round_to_tick() {
|
||||
use math::round_to_tick;
|
||||
|
||||
let price = Decimal::from_str("0.567").unwrap();
|
||||
let tick = Decimal::from_str("0.01").unwrap();
|
||||
let rounded = round_to_tick(price, tick);
|
||||
assert_eq!(rounded, Decimal::from_str("0.57").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mid_price() {
|
||||
use math::mid_price;
|
||||
|
||||
let bid = Decimal::from_str("0.50").unwrap();
|
||||
let ask = Decimal::from_str("0.52").unwrap();
|
||||
let mid = mid_price(bid, ask).unwrap();
|
||||
assert_eq!(mid, Decimal::from_str("0.51").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_units_conversion() {
|
||||
use math::{decimal_to_token_units, token_units_to_decimal};
|
||||
|
||||
let amount = Decimal::from_str("1.234567").unwrap();
|
||||
let units = decimal_to_token_units(amount);
|
||||
assert_eq!(units, 1_234_567);
|
||||
|
||||
let back = token_units_to_decimal(units);
|
||||
assert_eq!(back, amount);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_address_validation() {
|
||||
use address::parse_address;
|
||||
|
||||
let valid = "0x1234567890123456789012345678901234567890";
|
||||
assert!(parse_address(valid).is_ok());
|
||||
|
||||
let invalid = "invalid_address";
|
||||
assert!(parse_address(invalid).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Common utilities for integration tests
|
||||
|
||||
use polyfill_rs::{ClobClient, Result};
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Test configuration loaded from environment variables
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestConfig {
|
||||
pub host: String,
|
||||
pub chain_id: u64,
|
||||
pub private_key: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub api_secret: Option<String>,
|
||||
pub api_passphrase: Option<String>,
|
||||
pub test_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for TestConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: env::var("POLYMARKET_HOST").unwrap_or_else(|_| "https://clob.polymarket.com".to_string()),
|
||||
chain_id: env::var("POLYMARKET_CHAIN_ID")
|
||||
.unwrap_or_else(|_| "137".to_string())
|
||||
.parse()
|
||||
.unwrap_or(137),
|
||||
private_key: env::var("POLYMARKET_PRIVATE_KEY").ok(),
|
||||
api_key: env::var("POLYMARKET_API_KEY").ok(),
|
||||
api_secret: env::var("POLYMARKET_API_SECRET").ok(),
|
||||
api_passphrase: env::var("POLYMARKET_API_PASSPHRASE").ok(),
|
||||
test_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TestConfig {
|
||||
/// Check if we have authentication credentials
|
||||
pub fn has_auth(&self) -> bool {
|
||||
self.private_key.is_some()
|
||||
}
|
||||
|
||||
/// Check if we have API credentials
|
||||
pub fn has_api_creds(&self) -> bool {
|
||||
self.api_key.is_some() && self.api_secret.is_some() && self.api_passphrase.is_some()
|
||||
}
|
||||
|
||||
/// Create a basic client for testing
|
||||
pub fn create_basic_client(&self) -> ClobClient {
|
||||
ClobClient::new(&self.host)
|
||||
}
|
||||
|
||||
/// Create an authenticated client for testing
|
||||
pub fn create_auth_client(&self) -> Result<ClobClient> {
|
||||
let private_key = self.private_key.as_ref()
|
||||
.ok_or_else(|| polyfill_rs::PolyfillError::auth("No private key provided", polyfill_rs::errors::AuthErrorKind::InvalidCredentials))?;
|
||||
|
||||
Ok(ClobClient::with_l1_headers(&self.host, private_key, self.chain_id))
|
||||
}
|
||||
|
||||
/// Print test configuration (without sensitive data)
|
||||
pub fn print_config(&self) {
|
||||
println!("Test Configuration:");
|
||||
println!(" Host: {}", self.host);
|
||||
println!(" Chain ID: {}", self.chain_id);
|
||||
println!(" Has Auth: {}", self.has_auth());
|
||||
println!(" Has API Creds: {}", self.has_api_creds());
|
||||
println!(" Timeout: {:?}", self.test_timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test utilities for common operations
|
||||
pub struct TestUtils;
|
||||
|
||||
impl TestUtils {
|
||||
/// Get a valid token_id for testing
|
||||
pub async fn get_test_token_id(client: &ClobClient) -> Result<String> {
|
||||
let markets = client.get_sampling_markets(None).await?;
|
||||
if markets.data.is_empty() {
|
||||
return Err(polyfill_rs::PolyfillError::internal_simple("No markets available for testing"));
|
||||
}
|
||||
|
||||
let token_id = markets.data[0].tokens[0].token_id.clone();
|
||||
println!("Using test token_id: {}", token_id);
|
||||
Ok(token_id)
|
||||
}
|
||||
|
||||
/// Wait for a condition with timeout
|
||||
pub async fn wait_for<F, Fut>(mut condition: F, timeout: Duration) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool>>,
|
||||
{
|
||||
let start = std::time::Instant::now();
|
||||
let check_interval = Duration::from_millis(100);
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
if condition().await? {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(check_interval).await;
|
||||
}
|
||||
|
||||
Err(polyfill_rs::PolyfillError::timeout(
|
||||
timeout,
|
||||
"Condition not met within timeout".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Measure execution time of an async operation
|
||||
pub async fn measure_time<F, Fut, T>(operation: F) -> (T, Duration)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let start = std::time::Instant::now();
|
||||
let result = operation().await;
|
||||
let duration = start.elapsed();
|
||||
(result, duration)
|
||||
}
|
||||
|
||||
/// Assert that an operation completes within a reasonable time
|
||||
pub async fn assert_timely<F, Fut, T>(operation: F, max_duration: Duration) -> Result<T>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T>>,
|
||||
{
|
||||
let (result, duration) = Self::measure_time(|| async {
|
||||
operation().await
|
||||
}).await;
|
||||
|
||||
if duration > max_duration {
|
||||
return Err(polyfill_rs::PolyfillError::timeout(
|
||||
duration,
|
||||
format!("Operation took too long: {:?} > {:?}", duration, max_duration),
|
||||
));
|
||||
}
|
||||
|
||||
println!("Operation completed in {:?}", duration);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Test result reporting
|
||||
pub struct TestReporter;
|
||||
|
||||
impl TestReporter {
|
||||
/// Report test success
|
||||
pub fn success(test_name: &str) {
|
||||
println!("✅ {} passed", test_name);
|
||||
}
|
||||
|
||||
/// Report test failure
|
||||
pub fn failure(test_name: &str, error: &dyn std::error::Error) {
|
||||
println!("❌ {} failed: {}", test_name, error);
|
||||
}
|
||||
|
||||
/// Report test skip
|
||||
pub fn skip(test_name: &str, reason: &str) {
|
||||
println!("⚠️ {} skipped: {}", test_name, reason);
|
||||
}
|
||||
|
||||
/// Report test performance
|
||||
pub fn performance(test_name: &str, duration: Duration) {
|
||||
println!("⚡ {} completed in {:?}", test_name, duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Integration tests for polyfill-rs
|
||||
//!
|
||||
//! These tests verify that our client can actually communicate with the real Polymarket API.
|
||||
//! They require network connectivity and may take longer to run.
|
||||
|
||||
use polyfill_rs::{ClobClient, Result, PolyfillError};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use std::env;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
const POLYMARKET_HOST: &str = "https://clob.polymarket.com";
|
||||
const POLYGON_CHAIN_ID: u64 = 137;
|
||||
|
||||
/// Test configuration from environment variables
|
||||
struct TestConfig {
|
||||
private_key: Option<String>,
|
||||
api_key: Option<String>,
|
||||
api_secret: Option<String>,
|
||||
api_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl TestConfig {
|
||||
fn from_env() -> Self {
|
||||
Self {
|
||||
private_key: env::var("POLYMARKET_PRIVATE_KEY").ok(),
|
||||
api_key: env::var("POLYMARKET_API_KEY").ok(),
|
||||
api_secret: env::var("POLYMARKET_API_SECRET").ok(),
|
||||
api_passphrase: env::var("POLYMARKET_API_PASSPHRASE").ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_auth(&self) -> bool {
|
||||
self.private_key.is_some()
|
||||
}
|
||||
|
||||
fn has_api_creds(&self) -> bool {
|
||||
self.api_key.is_some() && self.api_secret.is_some() && self.api_passphrase.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that we can connect to Polymarket's API
|
||||
#[tokio::test]
|
||||
async fn test_api_connectivity() -> Result<()> {
|
||||
let client = ClobClient::new(POLYMARKET_HOST);
|
||||
|
||||
// Test basic connectivity
|
||||
let is_ok = client.get_ok().await;
|
||||
assert!(is_ok, "Failed to connect to Polymarket API");
|
||||
|
||||
// Test server time endpoint
|
||||
let server_time = client.get_server_time().await?;
|
||||
assert!(server_time > 0, "Invalid server time received");
|
||||
|
||||
println!("✅ API connectivity test passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test market data endpoints
|
||||
#[tokio::test]
|
||||
async fn test_market_data_endpoints() -> Result<()> {
|
||||
let client = ClobClient::new(POLYMARKET_HOST);
|
||||
|
||||
// Get sampling markets to find a valid token_id
|
||||
let markets_response = client.get_sampling_markets(None).await?;
|
||||
assert!(!markets_response.data.is_empty(), "No markets returned");
|
||||
|
||||
let first_market = &markets_response.data[0];
|
||||
let token_id = &first_market.tokens[0].token_id;
|
||||
|
||||
println!("Testing with token_id: {}", token_id);
|
||||
|
||||
// Test order book endpoint
|
||||
let order_book = client.get_order_book(token_id).await?;
|
||||
assert_eq!(order_book.asset_id, *token_id);
|
||||
assert!(!order_book.bids.is_empty() || !order_book.asks.is_empty(), "Empty order book");
|
||||
|
||||
// Test midpoint endpoint
|
||||
let midpoint = client.get_midpoint(token_id).await?;
|
||||
assert!(midpoint.mid > Decimal::ZERO, "Invalid midpoint");
|
||||
|
||||
// Test spread endpoint
|
||||
let spread = client.get_spread(token_id).await?;
|
||||
assert!(spread.spread >= Decimal::ZERO, "Invalid spread");
|
||||
|
||||
// Test price endpoints
|
||||
let buy_price = client.get_price(token_id, polyfill_rs::Side::BUY).await?;
|
||||
let sell_price = client.get_price(token_id, polyfill_rs::Side::SELL).await?;
|
||||
assert!(buy_price.price > Decimal::ZERO, "Invalid buy price");
|
||||
assert!(sell_price.price > Decimal::ZERO, "Invalid sell price");
|
||||
|
||||
// Test tick size endpoint
|
||||
let tick_size = client.get_tick_size(token_id).await?;
|
||||
assert!(tick_size > Decimal::ZERO, "Invalid tick size");
|
||||
|
||||
// Test neg risk endpoint
|
||||
let neg_risk = client.get_neg_risk(token_id).await?;
|
||||
// neg_risk is a boolean, so just verify it doesn't panic
|
||||
|
||||
println!("✅ Market data endpoints test passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test error handling with invalid requests
|
||||
#[tokio::test]
|
||||
async fn test_error_handling() -> Result<()> {
|
||||
let client = ClobClient::new(POLYMARKET_HOST);
|
||||
|
||||
// Test with invalid token_id
|
||||
let result = client.get_order_book("invalid_token_id").await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Some APIs might return empty data instead of error
|
||||
println!("⚠️ Invalid token_id returned data instead of error");
|
||||
}
|
||||
Err(e) => {
|
||||
match e {
|
||||
PolyfillError::Api { status, .. } => {
|
||||
assert!(status >= 400, "Expected client/server error for invalid token");
|
||||
}
|
||||
_ => {
|
||||
// Other error types are also acceptable
|
||||
println!("Received error for invalid token: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("✅ Error handling test passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test rate limiting behavior
|
||||
#[tokio::test]
|
||||
async fn test_rate_limiting() -> Result<()> {
|
||||
let client = ClobClient::new(POLYMARKET_HOST);
|
||||
|
||||
// Make multiple rapid requests to test rate limiting
|
||||
let mut results = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let result = client.get_server_time().await;
|
||||
results.push(result);
|
||||
|
||||
// Small delay between requests
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
// Most requests should succeed
|
||||
let success_count = results.iter().filter(|r| r.is_ok()).count();
|
||||
assert!(success_count >= 3, "Too many requests failed: {}/5", success_count);
|
||||
|
||||
println!("✅ Rate limiting test passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test compatibility with polymarket-rs-client API
|
||||
#[tokio::test]
|
||||
async fn test_api_compatibility() -> Result<()> {
|
||||
// Test that our client has the same basic structure as polymarket-rs-client
|
||||
let client = ClobClient::new(POLYMARKET_HOST);
|
||||
|
||||
// Test that we can call the same methods
|
||||
let _ = client.get_ok().await;
|
||||
let _ = client.get_server_time().await?;
|
||||
let _ = client.get_sampling_markets(None).await?;
|
||||
|
||||
// Test that our types are compatible
|
||||
let order_args = polyfill_rs::OrderArgs::new(
|
||||
"test_token",
|
||||
Decimal::from_str("0.5").map_err(|e| PolyfillError::parse(format!("Invalid decimal: {}", e), None))?,
|
||||
Decimal::from_str("1.0").map_err(|e| PolyfillError::parse(format!("Invalid decimal: {}", e), None))?,
|
||||
polyfill_rs::Side::BUY,
|
||||
);
|
||||
|
||||
assert_eq!(order_args.token_id, "test_token");
|
||||
assert_eq!(order_args.side, polyfill_rs::Side::BUY);
|
||||
|
||||
println!("✅ API compatibility test passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test performance characteristics
|
||||
#[tokio::test]
|
||||
async fn test_performance() -> Result<()> {
|
||||
let client = ClobClient::new(POLYMARKET_HOST);
|
||||
|
||||
// Test response time for basic operations
|
||||
let start = std::time::Instant::now();
|
||||
let _ = client.get_server_time().await?;
|
||||
let server_time_duration = start.elapsed();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let markets = client.get_sampling_markets(None).await?;
|
||||
let markets_duration = start.elapsed();
|
||||
|
||||
// Verify reasonable performance (adjust thresholds as needed)
|
||||
assert!(server_time_duration < Duration::from_secs(5), "Server time too slow: {:?}", server_time_duration);
|
||||
assert!(markets_duration < Duration::from_secs(10), "Markets request too slow: {:?}", markets_duration);
|
||||
|
||||
println!("✅ Performance test passed");
|
||||
println!(" Server time: {:?}", server_time_duration);
|
||||
println!(" Markets request: {:?}", markets_duration);
|
||||
println!(" Markets returned: {}", markets.data.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user