use std::collections::HashMap;
use std::env;
use std::time::{Duration, Instant};
use alloy::primitives::{Address, B256, U256, address};
use alloy::signers::local::PrivateKeySigner;
use perpcity_sdk::hft::latency::LatencyTracker;
use perpcity_sdk::hft::position_manager::{ManagedPosition, PositionManager, TriggerType};
use perpcity_sdk::transport::config::Strategy;
use perpcity_sdk::{
Deployments, HftTransport, OpenTakerParams, PerpClient, TransportConfig, Urgency,
};
const USDC: Address = address!("C1a5D4E99BB224713dd179eA9CA2Fa6600706210");
const MAX_BLOCKS: u32 = 30;
const TRADE_MARGIN: f64 = 10.0;
const TRADE_LEVERAGE: f64 = 5.0;
const STOP_LOSS_PCT: f64 = 0.02;
const TAKE_PROFIT_PCT: f64 = 0.05;
const TRAILING_STOP_PCT: f64 = 0.03;
fn load_signer() -> PrivateKeySigner {
env::var("PERPCITY_PRIVATE_KEY")
.expect("PERPCITY_PRIVATE_KEY must be set")
.parse::<PrivateKeySigner>()
.expect("invalid private key hex")
}
fn load_deployments() -> Deployments {
let perp_manager: Address = env::var("PERPCITY_MANAGER")
.expect("PERPCITY_MANAGER must be set")
.parse()
.expect("invalid PERPCITY_MANAGER address");
Deployments {
perp_manager,
usdc: USDC,
fees_module: None,
margin_ratios_module: None,
lockup_period_module: None,
sqrt_price_impact_limit_module: None,
}
}
fn load_perp_id() -> B256 {
env::var("PERPCITY_PERP_ID")
.expect("PERPCITY_PERP_ID must be set")
.parse::<B256>()
.expect("invalid PERPCITY_PERP_ID")
}
fn momentum_signal(prices: &[f64]) -> Option<bool> {
if prices.len() < 5 {
return None; }
let recent_avg: f64 = prices[prices.len() - 3..].iter().sum::<f64>() / 3.0;
let older_avg: f64 = prices[..prices.len() - 3].iter().sum::<f64>() / (prices.len() - 3) as f64;
let pct_change = (recent_avg - older_avg) / older_avg;
if pct_change > 0.001 {
Some(true) } else if pct_change < -0.001 {
Some(false) } else {
None }
}
#[tokio::main]
async fn main() -> perpcity_sdk::Result<()> {
dotenvy::dotenv().ok();
let perp_id = load_perp_id();
let rpc_1 = env::var("RPC_URL_1").unwrap_or_else(|_| "https://sepolia.base.org".into());
let rpc_2 = env::var("RPC_URL_2").ok();
let mut builder = TransportConfig::builder()
.shared_endpoint(&rpc_1)
.strategy(Strategy::LatencyBased)
.request_timeout(Duration::from_millis(2000));
if let Some(ref url) = rpc_2 {
builder = builder.shared_endpoint(url);
}
let transport = HftTransport::new(builder.build()?)?;
let health = transport.health_status();
println!("=== Transport Health ===");
for (i, status) in health.iter().enumerate() {
println!(
" Endpoint {i}: state={:?} avg_latency={:.1}ms errors={:.1}%",
status.state,
status.avg_latency_ns as f64 / 1_000_000.0,
status.error_rate * 100.0,
);
}
let client = PerpClient::new(transport, load_signer(), load_deployments(), 84532)?;
println!("\nHFT Bot — address: {}", client.address());
client.sync_nonce().await?;
client.refresh_gas().await?;
client.ensure_approval(U256::from(1_000_000_000u64)).await?;
let mut pos_manager = PositionManager::new();
let mut latency_tracker = LatencyTracker::new();
let mut trigger_buf: Vec<perpcity_sdk::hft::position_manager::TriggerAction> =
Vec::with_capacity(16);
let mut price_history: Vec<f64> = Vec::with_capacity(MAX_BLOCKS as usize);
let mut next_position_id_counter: u64 = 0;
let perp_config = client.get_perp_config(perp_id).await?;
println!("\n=== Market Config ===");
println!(
" Max leverage: {:.0}x",
perp_config.bounds.max_taker_leverage
);
println!(" Min margin: {:.2} USDC", perp_config.bounds.min_margin);
println!(" LP fee: {:.4}%", perp_config.fees.lp_fee * 100.0);
let balance = client.get_usdc_balance().await?;
println!(" Wallet USDC: {balance:.2}");
println!("\nStarting HFT loop ({MAX_BLOCKS} blocks)...\n");
for block in 0..MAX_BLOCKS {
let loop_start = Instant::now();
if let Err(e) = client.refresh_gas().await {
eprintln!(" [block {block}] gas refresh failed: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
client.invalidate_fast_cache();
let price_start = Instant::now();
let mark = match client.get_mark_price(perp_id).await {
Ok(p) => p,
Err(e) => {
eprintln!(" [block {block}] price fetch failed: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
};
let price_latency = price_start.elapsed();
latency_tracker.record(price_latency.as_nanos() as u64);
price_history.push(mark);
let perp_bytes: [u8; 32] = perp_id.into();
let mut prices_map: HashMap<[u8; 32], f64> = HashMap::new();
prices_map.insert(perp_bytes, mark);
trigger_buf.clear();
pos_manager.check_triggers_into(&prices_map, &mut trigger_buf);
for trigger in &trigger_buf {
let action_name = match trigger.trigger_type {
TriggerType::StopLoss => "STOP LOSS",
TriggerType::TakeProfit => "TAKE PROFIT",
TriggerType::TrailingStop => "TRAILING STOP",
};
println!(
" [block {block}] {action_name} triggered for position {} at price {:.6}",
trigger.position_id, trigger.trigger_price
);
pos_manager.untrack(trigger.position_id);
}
if pos_manager.count() == 0
&& let Some(is_long) = momentum_signal(&price_history)
{
let direction = if is_long { "LONG" } else { "SHORT" };
let (stop_loss, take_profit) = if is_long {
(mark * (1.0 - STOP_LOSS_PCT), mark * (1.0 + TAKE_PROFIT_PCT))
} else {
(mark * (1.0 + STOP_LOSS_PCT), mark * (1.0 - TAKE_PROFIT_PCT))
};
println!(
" [block {block}] Signal: {direction} at {mark:.6} | SL={stop_loss:.6} TP={take_profit:.6}"
);
let tx_start = Instant::now();
match client
.open_taker(
perp_id,
&OpenTakerParams {
is_long,
margin: TRADE_MARGIN,
leverage: TRADE_LEVERAGE,
unspecified_amount_limit: 0,
},
Urgency::High,
)
.await
{
Ok(open_result) => {
let tx_latency = tx_start.elapsed();
latency_tracker.record(tx_latency.as_nanos() as u64);
let pos_id_u64: u64 = open_result.pos_id.to::<u64>();
next_position_id_counter = pos_id_u64;
println!(
" [block {block}] Opened {direction} position #{pos_id_u64} \
(tx: {tx_latency:.0?})"
);
pos_manager.track(ManagedPosition {
perp_id: perp_bytes,
position_id: pos_id_u64,
is_long,
entry_price: mark,
margin: TRADE_MARGIN,
stop_loss: Some(stop_loss),
take_profit: Some(take_profit),
trailing_stop_pct: Some(TRAILING_STOP_PCT),
trailing_stop_anchor: None,
});
}
Err(e) => {
eprintln!(" [block {block}] Failed to open position: {e}");
}
}
}
let loop_time = loop_start.elapsed();
println!(
" [block {block}] mark={mark:.6} positions={} in_flight={} loop={loop_time:.0?}",
pos_manager.count(),
client.in_flight_count(),
);
let elapsed = loop_start.elapsed();
if elapsed < Duration::from_secs(1) {
tokio::time::sleep(Duration::from_secs(1) - elapsed).await;
}
}
println!("\n=== Shutting down ===");
if pos_manager.count() > 0 {
println!("Closing {} remaining positions...", pos_manager.count());
client.refresh_gas().await?;
println!(" Would close position #{next_position_id_counter}");
} else {
println!("No open positions to close.");
}
if let Some(stats) = latency_tracker.stats() {
println!("\n=== Latency Statistics ===");
println!(" Samples: {}", stats.count);
println!(" Min: {:.2} ms", stats.min_ns as f64 / 1_000_000.0);
println!(" Avg: {:.2} ms", stats.avg_ns as f64 / 1_000_000.0);
println!(" P50: {:.2} ms", stats.p50_ns as f64 / 1_000_000.0);
println!(" P95: {:.2} ms", stats.p95_ns as f64 / 1_000_000.0);
println!(" P99: {:.2} ms", stats.p99_ns as f64 / 1_000_000.0);
println!(" Max: {:.2} ms", stats.max_ns as f64 / 1_000_000.0);
}
println!("\n=== Final Transport Health ===");
for (i, status) in client.transport().health_status().iter().enumerate() {
println!(
" Endpoint {i}: state={:?} avg_latency={:.1}ms requests={} errors={:.1}%",
status.state,
status.avg_latency_ns as f64 / 1_000_000.0,
status.total_requests,
status.error_rate * 100.0,
);
}
println!("\nHFT bot complete.");
Ok(())
}