use polyfill_rs::{
book::OrderBookManager,
errors::Result,
fill::{FillEngine, FillStatus},
types::*,
utils::time,
};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{error, info, warn};
#[derive(Debug)]
pub struct SnipeStrategy {
token_id: String,
max_spread_pct: Decimal,
min_order_size: Decimal,
max_order_size: Decimal,
stale_threshold: u64,
last_best_bid: Option<Decimal>,
last_best_ask: Option<Decimal>,
last_update: u64,
book_manager: OrderBookManager,
fill_engine: FillEngine,
stats: SnipeStats,
}
#[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 {
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), 5, ),
stats: SnipeStats::default(),
}
}
pub fn process_update(&mut self, message: StreamMessage) -> Result<()> {
match message {
StreamMessage::Book(book) if book.asset_id == self.token_id => {
self.process_book_update(book)?;
},
StreamMessage::Trade(trade) if trade.asset_id == self.token_id => {
self.process_trade(trade)?;
},
_ => {},
}
self.check_stale_quotes()?;
Ok(())
}
fn process_book_update(&mut self, book: BookUpdate) -> Result<()> {
self.book_manager.get_or_create_book(&self.token_id)?;
if let Ok(current) = self.book_manager.get_book(&self.token_id) {
for level in ¤t.bids {
let _ = self.book_manager.apply_delta(OrderDelta {
token_id: self.token_id.clone(),
timestamp: chrono::Utc::now(),
side: Side::BUY,
price: level.price,
size: Decimal::ZERO,
sequence: book.timestamp,
});
}
for level in ¤t.asks {
let _ = self.book_manager.apply_delta(OrderDelta {
token_id: self.token_id.clone(),
timestamp: chrono::Utc::now(),
side: Side::SELL,
price: level.price,
size: Decimal::ZERO,
sequence: book.timestamp,
});
}
}
let ts = chrono::DateTime::from_timestamp(
(book.timestamp / 1000) as i64,
((book.timestamp % 1000) * 1_000_000) as u32,
)
.unwrap_or_else(chrono::Utc::now);
for level in &book.bids {
let _ = self.book_manager.apply_delta(OrderDelta {
token_id: self.token_id.clone(),
timestamp: ts,
side: Side::BUY,
price: level.price,
size: level.size,
sequence: book.timestamp,
});
}
for level in &book.asks {
let _ = self.book_manager.apply_delta(OrderDelta {
token_id: self.token_id.clone(),
timestamp: ts,
side: Side::SELL,
price: level.price,
size: level.size,
sequence: book.timestamp,
});
}
self.last_best_bid = book.bids.first().map(|l| l.price);
self.last_best_ask = book.asks.first().map(|l| l.price);
self.last_update = time::now_secs();
self.check_opportunities()?;
Ok(())
}
fn process_trade(&mut self, trade: TradeMessage) -> Result<()> {
info!(
"Trade: {} {} @ {} (size: {})",
trade.side.as_str(),
trade.asset_id,
trade.price,
trade.size
);
self.stats.total_volume += trade.size;
Ok(())
}
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(()), };
let spread_pct = match (bid, ask) {
(bid, ask) if bid > dec!(0) && ask > bid => (ask - bid) / bid * dec!(100),
_ => return Ok(()),
};
if spread_pct <= self.max_spread_pct {
self.stats.opportunities_detected += 1;
info!(
"Opportunity detected: spread {}% (target: {}%)",
spread_pct, self.max_spread_pct
);
self.execute_snipe_order(bid, ask)?;
}
Ok(())
}
fn execute_snipe_order(&mut self, bid: Decimal, ask: Decimal) -> Result<()> {
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;
let side = if bid > ask {
Side::SELL } else {
Side::BUY };
let request = MarketOrderRequest {
token_id: self.token_id.clone(),
side,
amount: size,
slippage_tolerance: Some(dec!(1.0)), client_id: Some(format!("snipe_{}", time::now_millis())),
};
let book = self.book_manager.get_book(&self.token_id)?;
let mut book_impl = polyfill_rs::book::OrderBook::new(self.token_id.clone(), 100);
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,
})?;
}
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;
self.stats.orders_placed += 1;
if result.status == FillStatus::Filled {
self.stats.orders_filled += 1;
}
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(())
}
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
);
}
Ok(())
}
pub fn get_stats(&self) -> &SnipeStats {
&self.stats
}
}
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), sequence: 0,
}
}
fn generate_update(&mut self) -> StreamMessage {
self.sequence += 1;
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);
let size = Decimal::from(rand::random::<u64>() % 1000 + 100);
let bid = new_price - dec!(0.01);
let ask = new_price + dec!(0.01);
StreamMessage::Book(BookUpdate {
asset_id: self.token_id.clone(),
market: "0xmock".to_string(),
timestamp: time::now_millis(),
bids: vec![OrderSummary { price: bid, size }],
asks: vec![OrderSummary { price: ask, size }],
hash: None,
})
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
info!("Starting snipe trading example...");
let mut strategy = SnipeStrategy::new(
"12345".to_string(), dec!(2.0), dec!(10), dec!(100), 5, );
let mut market_data = MockMarketData::new(
"12345".to_string(),
dec!(0.5), );
let mut message_count = 0;
let max_messages = 100;
while message_count < max_messages {
let update = market_data.generate_update();
if let Err(e) = strategy.process_update(update) {
error!("Error processing update: {}", e);
}
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; }
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(())
}