Files
sol-hunter/src/strategy/veloc.rs
T
2026-08-07 09:25:51 +02:00

276 lines
9.8 KiB
Rust

use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant};
use log::{debug, info, trace, warn};
use rust_decimal::{Decimal, dec};
use crate::bot::Bot;
use crate::data::tradelog::{ExitReason, TradeLog};
use crate::strategy::Strategy;
use crate::types::{NewToken, Trade, TradeType};
const BUY_AMOUNT_SOL: Decimal = dec!(0.2);
const PRIORITY: Decimal = dec!(0.0002);
const SLIPPAGE: u16 = 10;
const MAX_SUBSCRIBED_TOKENS: usize = 5;
struct TokenTracker {
created_at: Instant,
unique_buyers: HashSet<String>,
net_sol_flow: f64,
trade_count: usize,
}
struct OpenPosition {
trade: TradeLog,
highest_price_sol: f64,
last_high_time: Instant,
}
pub struct MomentumVelocityStrategy {
min_unique_buyers: usize,
min_net_sol_flow: f64,
max_tracking_duration: Duration,
trackers: HashMap<String, TokenTracker>,
positions: HashMap<String, OpenPosition>,
// Tracks active token subscriptions to enforce <= 5 limit
active_subscriptions: VecDeque<String>,
}
impl MomentumVelocityStrategy {
pub fn new() -> Self {
Self {
min_unique_buyers: 1,
min_net_sol_flow: 0.001,
max_tracking_duration: Duration::from_secs(45),
trackers: HashMap::new(),
positions: HashMap::new(),
active_subscriptions: VecDeque::with_capacity(MAX_SUBSCRIBED_TOKENS),
}
}
fn calculate_price_sol(&self, trade: &Trade) -> f64 {
if trade.v_tokens_in_bonding_curve == 0.0 {
return 0.0;
}
trade.v_sol_in_bonding_curve / trade.v_tokens_in_bonding_curve
}
/// Internal helper to safely handle unsubscribing and cleaning state
async fn cleanup_and_unsubscribe(&mut self, bot: &Arc<Bot>, mint: &str) -> anyhow::Result<()> {
debug!("[{}] Cleaning up state and unsubscribing", mint);
if let Err(e) = bot.unsubscribe(mint).await {
warn!("[{}] Unsubscribe request failed: {:?}", mint, e);
}
self.trackers.remove(mint);
self.active_subscriptions.retain(|m| m != mint);
Ok(())
}
}
#[async_trait::async_trait]
impl Strategy for MomentumVelocityStrategy {
async fn execute_sell_all(&mut self, bot: Arc<Bot>) -> anyhow::Result<()> {
bot.executor.lock().await.sell_all(PRIORITY, SLIPPAGE).await
}
async fn on_new_coin(&mut self, bot: Arc<Bot>, token: NewToken) -> anyhow::Result<()> {
trace!("[NEW COIN] Event received for token: {}", token.mint);
if self.positions.contains_key(&token.mint) || self.trackers.contains_key(&token.mint) {
trace!(
"[{}] Already tracking or holding position. Skipping.",
token.mint
);
return Ok(());
}
// Evict oldest tracked token that DOES NOT have an active open position
while self.active_subscriptions.len() >= MAX_SUBSCRIBED_TOKENS {
let eviction_index = self
.active_subscriptions
.iter()
.position(|mint| !self.positions.contains_key(mint));
if let Some(idx) = eviction_index {
if let Some(mint_to_remove) = self.active_subscriptions.remove(idx) {
info!(
"[{}] Capacity reached ({}/{}). Evicting un-bought token from queue.",
mint_to_remove, MAX_SUBSCRIBED_TOKENS, MAX_SUBSCRIBED_TOKENS
);
let _ = bot.unsubscribe(&mint_to_remove).await;
self.trackers.remove(&mint_to_remove);
}
} else {
warn!(
"[QUEUE FULL] All {} slots are occupied by active positions. Cannot track {}",
MAX_SUBSCRIBED_TOKENS, token.mint
);
return Ok(());
}
}
info!("[{}] Subscribing and creating tracker.", token.mint);
bot.subscribe(&token.mint).await?;
self.active_subscriptions.push_back(token.mint.clone());
self.trackers.insert(
token.mint,
TokenTracker {
created_at: Instant::now(),
unique_buyers: HashSet::new(),
net_sol_flow: 0.0,
trade_count: 0,
},
);
Ok(())
}
async fn on_trade(&mut self, bot: Arc<Bot>, trade: Trade) -> anyhow::Result<()> {
let mint = &trade.mint;
let current_price = self.calculate_price_sol(&trade);
// -------------------------------------------------------------
// 1. Manage Active Positions (Take Profit / Stop Loss / Stall)
// -------------------------------------------------------------
if let Some(pos) = self.positions.get_mut(mint) {
let price_change_pct =
(current_price - pos.trade.entry_price_sol) / pos.trade.entry_price_sol;
if current_price > pos.highest_price_sol {
pos.highest_price_sol = current_price;
pos.last_high_time = Instant::now();
trace!("[{}] New high reached: {:.9} SOL", mint, current_price);
}
let drop_from_peak = (pos.highest_price_sol - current_price) / pos.highest_price_sol;
let should_sell = match () {
_ if price_change_pct >= 0.40 => Some(ExitReason::TakeProfit),
_ if price_change_pct <= -0.1 => Some(ExitReason::StopLoss),
_ if drop_from_peak >= 0.12 && price_change_pct > 0.10 => {
Some(ExitReason::TrailingStop)
}
_ if pos.last_high_time.elapsed() >= Duration::from_secs(25) => {
Some(ExitReason::MomentumStalled)
}
_ => None,
};
if let Some(reason) = should_sell {
info!(
"[{}] EXECUTING SELL. Reason: {:?} {:.1}%",
mint,
reason,
price_change_pct * 100.0
);
bot.executor
.lock()
.await
.sell_percent(mint, 100, PRIORITY, SLIPPAGE)
.await?;
if let Some(mut pos) = self.positions.remove(mint) {
pos.trade.close(current_price, reason);
bot.trade_log.lock().await.push(pos.trade);
}
self.cleanup_and_unsubscribe(&bot, mint).await?;
}
return Ok(());
}
// -------------------------------------------------------------
// 2. Evaluate Potential Buys
// -------------------------------------------------------------
if let Some(tracker) = self.trackers.get_mut(mint) {
let elapsed = tracker.created_at.elapsed();
if elapsed > self.max_tracking_duration {
info!(
"[{}] Tracking window expired ({:?} > {:?}). Cleaning up.",
mint, elapsed, self.max_tracking_duration
);
self.cleanup_and_unsubscribe(&bot, mint).await?;
return Ok(());
}
tracker.trade_count += 1;
match trade.tx_type {
TradeType::Buy => {
tracker.unique_buyers.insert(trade.trader.clone());
tracker.net_sol_flow += trade.sol_amount;
}
TradeType::Sell => {
tracker.net_sol_flow -= trade.sol_amount;
}
}
let has_enough_buyers = tracker.unique_buyers.len() >= self.min_unique_buyers;
let has_volume_surge = tracker.net_sol_flow >= self.min_net_sol_flow;
let v_sol = trade.v_sol_in_bonding_curve / 1_000_000_000.0;
let is_early_curve = v_sol < 60.0;
// Log detailed status of buy criteria evaluation on every trade
debug!(
"[{}] Trade #{} ({:?}) | Buyers: {}/{} [{}] | Net Flow: {:.3}/{:.3} SOL [{}] | Curve SOL: {:.2} < 60 [{}]",
mint,
tracker.trade_count,
trade.tx_type,
tracker.unique_buyers.len(),
self.min_unique_buyers,
if has_enough_buyers { "PASS" } else { "FAIL" },
tracker.net_sol_flow,
self.min_net_sol_flow,
if has_volume_surge { "PASS" } else { "FAIL" },
trade.v_sol_in_bonding_curve,
if is_early_curve { "PASS" } else { "FAIL" }
);
if has_enough_buyers && has_volume_surge && is_early_curve {
info!(
"🚀 BUY SIGNAL TRIGGERED for {}! Unique Buyers: {}, Net Flow: {:.3} SOL, Curve SOL: {:.2}",
mint,
tracker.unique_buyers.len(),
tracker.net_sol_flow,
trade.v_sol_in_bonding_curve
);
bot.executor
.lock()
.await
.buy(mint, BUY_AMOUNT_SOL, PRIORITY, SLIPPAGE)
.await?;
self.positions.insert(
mint.clone(),
OpenPosition {
trade: TradeLog::new(
mint.clone(),
current_price,
tracker.unique_buyers.len(),
tracker.net_sol_flow,
trade.v_sol_in_bonding_curve,
),
highest_price_sol: current_price,
last_high_time: Instant::now(),
},
);
self.trackers.remove(mint);
}
} else {
trace!("[{}] Received trade for untracked mint.", mint);
}
Ok(())
}
}