Merge pull request #113 from chainstacklabs/feat/take-profit-stop-loss

Add PumpPortal listener, simple TP and SL functionality
This commit is contained in:
Ake
2025-06-16 10:31:39 +08:00
committed by GitHub
11 changed files with 891 additions and 40 deletions
+7
View File
@@ -24,6 +24,13 @@ trade:
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%) buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%)
sell_slippage: 0.3 sell_slippage: 0.3
# Exit strategy configuration
exit_strategy: "time_based" # Options: "time_based", "tp_sl", "manual"
#take_profit_percentage: 0.1 # Take profit at 10% gain (0.1 = 10%)
#stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%)
max_hold_time: 15 # Maximum hold time in seconds
#price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration # EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check. # When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise. # The bot buys the specified number of tokens directly, making the process faster but less precise.
+9 -2
View File
@@ -8,7 +8,7 @@ rpc_endpoint: "${SOLANA_NODE_RPC_ENDPOINT}"
wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}" wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}"
private_key: "${SOLANA_PRIVATE_KEY}" private_key: "${SOLANA_PRIVATE_KEY}"
enabled: false # You can turn off the bot w/o removing its config enabled: true # You can turn off the bot w/o removing its config
separate_process: true separate_process: true
# Geyser configuration (fastest method for getting updates) # Geyser configuration (fastest method for getting updates)
@@ -21,9 +21,16 @@ geyser:
# Control trade execution: amount of SOL per trade and acceptable price deviation # Control trade execution: amount of SOL per trade and acceptable price deviation
trade: trade:
buy_amount: 0.0001 # Amount of SOL to spend when buying (in SOL) buy_amount: 0.0001 # Amount of SOL to spend when buying (in SOL)
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%) buy_slippage: 0.2 # Maximum acceptable price deviation (0.2 = 20%)
sell_slippage: 0.3 sell_slippage: 0.3
# Exit strategy configuration
exit_strategy: "tp_sl" # Options: "time_based", "tp_sl", "manual"
take_profit_percentage: 0.2 # Take profit at 20% gain (0.2 = 20%)
stop_loss_percentage: 0.2 # Stop loss at 20% loss (0.2 = 20%)
max_hold_time: 60 # Maximum hold time in seconds
price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration # EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check. # When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise. # The bot buys the specified number of tokens directly, making the process faster but less precise.
+8 -1
View File
@@ -8,7 +8,7 @@ rpc_endpoint: "${SOLANA_NODE_RPC_ENDPOINT}"
wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}" wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}"
private_key: "${SOLANA_PRIVATE_KEY}" private_key: "${SOLANA_PRIVATE_KEY}"
enabled: true # You can turn off the bot w/o removing its config enabled: false # You can turn off the bot w/o removing its config
separate_process: true separate_process: true
# Geyser configuration (fastest method for getting updates) # Geyser configuration (fastest method for getting updates)
@@ -24,6 +24,13 @@ trade:
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%) buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%)
sell_slippage: 0.3 sell_slippage: 0.3
# Exit strategy configuration
exit_strategy: "time_based" # Options: "time_based", "tp_sl", "manual"
#take_profit_percentage: 0.1 # Take profit at 10% gain (0.1 = 10%)
#stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%)
max_hold_time: 15 # Maximum hold time in seconds
#price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration # EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check. # When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise. # The bot buys the specified number of tokens directly, making the process faster but less precise.
+77
View File
@@ -0,0 +1,77 @@
# This file defines comprehensive parameters and settings for the trading bot.
# Carefully review and adjust values to match your trading strategy and risk tolerance.
# Bot identification and connection settings
name: "bot-sniper-pumpportal"
env_file: ".env"
rpc_endpoint: "${SOLANA_NODE_RPC_ENDPOINT}"
wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}"
private_key: "${SOLANA_PRIVATE_KEY}"
enabled: false # You can turn off the bot w/o removing its config
separate_process: true
# PumpPortal configuration (optional - uses default URL if not specified)
pumpportal:
url: "wss://pumpportal.fun/api/data" # Default PumpPortal WebSocket URL
# Trading parameters
# Control trade execution: amount of SOL per trade and acceptable price deviation
trade:
buy_amount: 0.0001 # Amount of SOL to spend when buying (in SOL)
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%)
sell_slippage: 0.3
# Exit strategy configuration
exit_strategy: "tp_sl" # Options: "time_based", "tp_sl", "manual"
take_profit_percentage: 0.1 # Take profit at 10% gain (0.1 = 10%)
stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%)
max_hold_time: 600 # Maximum hold time in seconds (600 = 10 minutes)
price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise.
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
# Priority fee configuration
# Manage transaction speed and cost on the Solana network.
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
priority_fees:
enable_dynamic: false # Use latest transactions to estimate required fee (getRecentPrioritizationFees)
enable_fixed: true # Use fixed amount below
fixed_amount: 200_000 # Base fee in microlamports
extra_percentage: 0.0 # Percentage increase on riority fee regardless of the calculation method (0.1 = 10%)
hard_cap: 200_000 # Maximum allowable fee in microlamports to prevent excessive spending
# Filters for token selection
filters:
match_string: null # Only process tokens with this string in name/symbol
bro_address: null # Only trade tokens created by this user address
listener_type: "pumpportal" # Method for detecting new tokens: "logs", "blocks", "geyser", or "pumpportal"
max_token_age: 0.001 # Maximum token age in seconds for processing
marry_mode: false # Only buy tokens, skip selling
yolo_mode: false # Continuously trade tokens
# Retry and timeout settings
retries:
max_attempts: 1 # Number of attempts for transaction submission
wait_after_creation: 15 # Seconds to wait after token creation (only if EXTREME FAST is disabled)
wait_after_buy: 15 # Holding period after buy transaction
wait_before_new_token: 15 # Pause between token trades
# Token and account management
cleanup:
# Cleanup mode determines when to manage token accounts. Options:
# "disabled": no cleanup will occur.
# "on_fail": only clean up if a buy transaction fails.
# "after_sell": clean up after selling.
# "post_session": clean up all empty accounts after a trading session ends.
mode: "post_session"
force_close_with_burn: false # Force burning remaining tokens before closing account
with_priority_fee: false # Use priority fees for cleanup transactions
# Node provider configuration (not implemented)
node:
max_rps: 25 # Maximum requests per second
+10
View File
@@ -54,6 +54,13 @@ async def start_bot(config_path: str):
extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False), extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False),
extreme_fast_token_amount=cfg["trade"].get("extreme_fast_token_amount", 30), extreme_fast_token_amount=cfg["trade"].get("extreme_fast_token_amount", 30),
# Exit strategy configuration
exit_strategy=cfg["trade"].get("exit_strategy", "time_based"),
take_profit_percentage=cfg["trade"].get("take_profit_percentage"),
stop_loss_percentage=cfg["trade"].get("stop_loss_percentage"),
max_hold_time=cfg["trade"].get("max_hold_time"),
price_check_interval=cfg["trade"].get("price_check_interval", 10),
# Listener configuration # Listener configuration
listener_type=cfg["filters"]["listener_type"], listener_type=cfg["filters"]["listener_type"],
@@ -62,6 +69,9 @@ async def start_bot(config_path: str):
geyser_api_token=cfg.get("geyser", {}).get("api_token"), geyser_api_token=cfg.get("geyser", {}).get("api_token"),
geyser_auth_type=cfg.get("geyser", {}).get("auth_type"), geyser_auth_type=cfg.get("geyser", {}).get("auth_type"),
# PumpPortal configuration (if applicable)
pumpportal_url=cfg.get("pumpportal", {}).get("url", "wss://pumpportal.fun/api/data"),
# Priority fee configuration # Priority fee configuration
enable_dynamic_priority_fee=cfg.get("priority_fees", {}).get("enable_dynamic", False), enable_dynamic_priority_fee=cfg.get("priority_fees", {}).get("enable_dynamic", False),
enable_fixed_priority_fee=cfg.get("priority_fees", {}).get("enable_fixed", True), enable_fixed_priority_fee=cfg.get("priority_fees", {}).get("enable_fixed", True),
+3 -2
View File
@@ -24,8 +24,9 @@ CONFIG_VALIDATION_RULES = [
# Valid values for enum-like fields # Valid values for enum-like fields
VALID_VALUES = { VALID_VALUES = {
"filters.listener_type": ["logs", "blocks", "geyser"], "filters.listener_type": ["logs", "blocks", "geyser", "pumpportal"],
"cleanup.mode": ["disabled", "on_fail", "after_sell", "post_session"] "cleanup.mode": ["disabled", "on_fail", "after_sell", "post_session"],
"trade.exit_strategy": ["time_based", "tp_sl", "manual"]
} }
@@ -0,0 +1,126 @@
"""
Event processing for pump.fun tokens using PumpPortal data.
"""
from solders.pubkey import Pubkey
from core.pubkeys import PumpAddresses, SystemAddresses
from trading.base import TokenInfo
from utils.logger import get_logger
logger = get_logger(__name__)
class PumpPortalEventProcessor:
"""Processes token creation events from PumpPortal WebSocket."""
def __init__(self, pump_program: Pubkey):
"""Initialize event processor.
Args:
pump_program: Pump.fun program address
"""
self.pump_program = pump_program
def process_token_data(self, token_data: dict) -> TokenInfo | None:
"""Process token data from PumpPortal and extract token creation info.
Args:
token_data: Token data from PumpPortal WebSocket
Returns:
TokenInfo if token creation found, None otherwise
"""
try:
# Extract required fields
name = token_data.get("name", "")
symbol = token_data.get("symbol", "")
mint_str = token_data.get("mint")
bonding_curve_str = token_data.get("bondingCurveKey")
creator_str = token_data.get("traderPublicKey") # Maps to user field
uri = token_data.get("uri", "")
# Additional fields available from PumpPortal but not used:
# - initialBuy: Initial buy amount in SOL
# - marketCapSol: Market cap in SOL
# - vSolInBondingCurve: Virtual SOL in bonding curve
# - vTokensInBondingCurve: Virtual tokens in bonding curve
# - signature: Transaction signature
if not all([name, symbol, mint_str, bonding_curve_str, creator_str]):
logger.warning("Missing required fields in PumpPortal token data")
return None
# Convert string addresses to Pubkey objects
mint = Pubkey.from_string(mint_str)
bonding_curve = Pubkey.from_string(bonding_curve_str)
user = Pubkey.from_string(creator_str)
# For PumpPortal, we assume the creator is the same as the user
# since PumpPortal doesn't distinguish between them
creator = user
# Calculate derived addresses
associated_bonding_curve = self._find_associated_bonding_curve(
mint, bonding_curve
)
creator_vault = self._find_creator_vault(creator)
return TokenInfo(
name=name,
symbol=symbol,
uri=uri,
mint=mint,
bonding_curve=bonding_curve,
associated_bonding_curve=associated_bonding_curve,
user=user,
creator=creator,
creator_vault=creator_vault,
)
except Exception as e:
logger.error(f"Failed to process PumpPortal token data: {e}")
return None
def _find_associated_bonding_curve(
self, mint: Pubkey, bonding_curve: Pubkey
) -> Pubkey:
"""
Find the associated bonding curve for a given mint and bonding curve.
This uses the standard ATA derivation.
Args:
mint: Token mint address
bonding_curve: Bonding curve address
Returns:
Associated bonding curve address
"""
derived_address, _ = Pubkey.find_program_address(
[
bytes(bonding_curve),
bytes(SystemAddresses.TOKEN_PROGRAM),
bytes(mint),
],
SystemAddresses.ASSOCIATED_TOKEN_PROGRAM,
)
return derived_address
def _find_creator_vault(self, creator: Pubkey) -> Pubkey:
"""
Find the creator vault for a creator.
Args:
creator: Creator address
Returns:
Creator vault address
"""
derived_address, _ = Pubkey.find_program_address(
[
b"creator-vault",
bytes(creator)
],
PumpAddresses.PROGRAM,
)
return derived_address
+170
View File
@@ -0,0 +1,170 @@
"""
PumpPortal monitoring for pump.fun tokens.
"""
import asyncio
import json
from collections.abc import Awaitable, Callable
import websockets
from solders.pubkey import Pubkey
from monitoring.base_listener import BaseTokenListener
from monitoring.pumpportal_event_processor import PumpPortalEventProcessor
from trading.base import TokenInfo
from utils.logger import get_logger
logger = get_logger(__name__)
class PumpPortalListener(BaseTokenListener):
"""PumpPortal listener for pump.fun token creation events."""
def __init__(self, pump_program: Pubkey, pumpportal_url: str = "wss://pumpportal.fun/api/data"):
"""Initialize token listener.
Args:
pump_program: Pump.fun program address
pumpportal_url: PumpPortal WebSocket URL
"""
self.pump_program = pump_program
self.pumpportal_url = pumpportal_url
self.event_processor = PumpPortalEventProcessor(pump_program)
self.ping_interval = 20 # seconds
async def listen_for_tokens(
self,
token_callback: Callable[[TokenInfo], Awaitable[None]],
match_string: str | None = None,
creator_address: str | None = None,
) -> None:
"""Listen for new token creations using PumpPortal WebSocket.
Args:
token_callback: Callback function for new tokens
match_string: Optional string to match in token name/symbol
creator_address: Optional creator address to filter by
"""
while True:
try:
async with websockets.connect(self.pumpportal_url) as websocket:
await self._subscribe_to_new_tokens(websocket)
ping_task = asyncio.create_task(self._ping_loop(websocket))
try:
while True:
token_info = await self._wait_for_token_creation(websocket)
if not token_info:
continue
logger.info(
f"New token detected: {token_info.name} ({token_info.symbol})"
)
if match_string and not (
match_string.lower() in token_info.name.lower()
or match_string.lower() in token_info.symbol.lower()
):
logger.info(
f"Token does not match filter '{match_string}'. Skipping..."
)
continue
if (
creator_address
and str(token_info.user) != creator_address
):
logger.info(
f"Token not created by {creator_address}. Skipping..."
)
continue
await token_callback(token_info)
except websockets.exceptions.ConnectionClosed:
logger.warning("PumpPortal WebSocket connection closed. Reconnecting...")
finally:
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("PumpPortal WebSocket connection error")
logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
async def _subscribe_to_new_tokens(self, websocket) -> None:
"""Subscribe to new token events from PumpPortal.
Args:
websocket: Active WebSocket connection
"""
subscription_message = json.dumps({
"method": "subscribeNewToken",
"params": []
})
await websocket.send(subscription_message)
logger.info("Subscribed to PumpPortal new token events")
async def _ping_loop(self, websocket) -> None:
"""Keep connection alive with pings.
Args:
websocket: Active WebSocket connection
"""
try:
while True:
await asyncio.sleep(self.ping_interval)
try:
pong_waiter = await websocket.ping()
await asyncio.wait_for(pong_waiter, timeout=10)
except TimeoutError:
logger.warning("Ping timeout - PumpPortal server not responding")
# Force reconnection
await websocket.close()
return
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Ping error: {e}")
async def _wait_for_token_creation(self, websocket) -> TokenInfo | None:
"""Wait for token creation event from PumpPortal.
Args:
websocket: Active WebSocket connection
Returns:
TokenInfo if a token creation is found, None otherwise
"""
try:
response = await asyncio.wait_for(websocket.recv(), timeout=30)
data = json.loads(response)
# Handle different message formats from PumpPortal
token_info = None
if "method" in data and data["method"] == "newToken":
# Standard newToken method format
params = data.get("params", [])
if params and len(params) > 0:
token_data = params[0]
token_info = self.event_processor.process_token_data(token_data)
elif "signature" in data and "mint" in data:
# Direct token data format
token_info = self.event_processor.process_token_data(data)
return token_info
except TimeoutError:
logger.debug("No data received from PumpPortal for 30 seconds")
except websockets.exceptions.ConnectionClosed:
logger.warning("PumpPortal WebSocket connection closed")
raise
except json.JSONDecodeError as e:
logger.error(f"Failed to decode PumpPortal message: {e}")
except Exception as e:
logger.error(f"Error processing PumpPortal WebSocket message: {e}")
return None
+139 -2
View File
@@ -113,12 +113,17 @@ class TokenBuyer(Trader):
success = await self.client.confirm_transaction(tx_signature) success = await self.client.confirm_transaction(tx_signature)
if success: if success:
# Get actual execution data from bonding curve balance changes
actual_price, actual_tokens = await self._get_actual_execution_price(tx_signature, token_info)
logger.info(f"Buy transaction confirmed: {tx_signature}") logger.info(f"Buy transaction confirmed: {tx_signature}")
logger.info(f"Actual price paid to bonding curve: {actual_price:.8f} SOL per token")
return TradeResult( return TradeResult(
success=True, success=True,
tx_signature=tx_signature, tx_signature=tx_signature,
amount=token_amount, amount=actual_tokens, # Actual tokens received
price=token_price_sol, price=actual_price, # Actual price based on bonding curve SOL flow
) )
else: else:
return TradeResult( return TradeResult(
@@ -216,3 +221,135 @@ class TokenBuyer(Trader):
except Exception as e: except Exception as e:
logger.error(f"Buy transaction failed: {e!s}") logger.error(f"Buy transaction failed: {e!s}")
raise raise
async def _get_actual_execution_price(self, tx_signature: str, token_info: TokenInfo) -> tuple[float, float]:
"""Get actual execution price from bonding curve SOL balance changes."""
try:
client = await self.client.get_client()
tx_response = await client.get_transaction(
tx_signature,
encoding="jsonParsed",
commitment="confirmed",
max_supported_transaction_version=0
)
if not tx_response.value or not tx_response.value.transaction:
raise ValueError("Transaction not found")
meta = tx_response.value.transaction.meta
if not meta or not meta.pre_balances or not meta.post_balances:
raise ValueError("Transaction balance data not found")
# Get accounts - they're ParsedAccountTxStatus objects, need to extract pubkey
accounts = tx_response.value.transaction.transaction.message.account_keys
# Find bonding curve account index in the transaction
bonding_curve_index = None
for i, account in enumerate(accounts):
# Extract pubkey from ParsedAccountTxStatus object
account_pubkey = str(account.pubkey) if hasattr(account, 'pubkey') else str(account)
if account_pubkey == str(token_info.bonding_curve):
bonding_curve_index = i
break
if bonding_curve_index is None:
raise ValueError("Bonding curve not found in transaction accounts")
pre_balance_lamports = meta.pre_balances[bonding_curve_index]
post_balance_lamports = meta.post_balances[bonding_curve_index]
sol_sent_to_curve = (post_balance_lamports - pre_balance_lamports) / LAMPORTS_PER_SOL
if sol_sent_to_curve <= 0:
raise ValueError(f"No SOL sent to bonding curve: {sol_sent_to_curve}")
tokens_received = await self._get_tokens_received_from_tx(tx_response, token_info)
if tokens_received == 0:
raise ValueError("Cannot compute execution price: zero tokens received")
actual_price = sol_sent_to_curve / tokens_received
logger.info(f"Bonding curve received: {sol_sent_to_curve:.6f} SOL")
logger.info(f"We received: {tokens_received:.6f} tokens")
logger.info(f"Actual execution price: {actual_price:.8f} SOL per token")
return actual_price, tokens_received
except Exception as e:
logger.warning(f"Failed to get actual execution price from bonding curve: {e}")
# Fallback to EXTREME_FAST estimate
tokens_received = self.extreme_fast_token_amount if self.extreme_fast_mode else self.amount / await self.curve_manager.calculate_price(token_info.bonding_curve)
if tokens_received == 0:
logger.error("Fallback failed unable to determine tokens received")
return 0.0, 0.0
return self.amount / tokens_received, tokens_received
async def _get_tokens_received_from_tx(self, tx_response, token_info: TokenInfo) -> float:
"""Extract tokens received from transaction token balance changes."""
meta = tx_response.value.transaction.meta
pre_token_balance = 0
post_token_balance = 0
wallet_str = str(self.wallet.pubkey)
mint_str = str(token_info.mint)
if meta.pre_token_balances:
for balance in meta.pre_token_balances:
# Convert to string for comparison
balance_owner = str(balance.owner) if hasattr(balance, 'owner') else str(getattr(balance, 'owner', ''))
balance_mint = str(balance.mint) if hasattr(balance, 'mint') else str(getattr(balance, 'mint', ''))
if balance_owner == wallet_str and balance_mint == mint_str:
try:
# Try multiple ways to get the amount
if hasattr(balance, 'ui_token_amount'):
amount_obj = balance.ui_token_amount
if hasattr(amount_obj, 'amount') and amount_obj.amount is not None:
pre_token_balance = int(amount_obj.amount)
elif hasattr(amount_obj, 'ui_amount') and amount_obj.ui_amount is not None:
pre_token_balance = int(float(amount_obj.ui_amount) * (10**TOKEN_DECIMALS))
except (ValueError, TypeError) as e:
logger.warning(f"Error parsing pre-token balance: {e}")
break
# Check post-token balances
if meta.post_token_balances:
for balance in meta.post_token_balances:
# Convert to string for comparison
balance_owner = str(balance.owner) if hasattr(balance, 'owner') else str(getattr(balance, 'owner', ''))
balance_mint = str(balance.mint) if hasattr(balance, 'mint') else str(getattr(balance, 'mint', ''))
if balance_owner == wallet_str and balance_mint == mint_str:
try:
# Try multiple ways to get the amount
if hasattr(balance, 'ui_token_amount'):
amount_obj = balance.ui_token_amount
if hasattr(amount_obj, 'amount') and amount_obj.amount is not None:
post_token_balance = int(amount_obj.amount)
elif hasattr(amount_obj, 'ui_amount') and amount_obj.ui_amount is not None:
post_token_balance = int(float(amount_obj.ui_amount) * (10**TOKEN_DECIMALS))
except (ValueError, TypeError) as e:
logger.warning(f"Error parsing post-token balance: {e}")
break
# Calculate tokens received
if pre_token_balance == 0 and post_token_balance > 0:
tokens_received_raw = post_token_balance
else:
tokens_received_raw = post_token_balance - pre_token_balance
if tokens_received_raw <= 0:
logger.warning("Token balance search failed. Using fallback from EXTREME_FAST estimate.")
# Fallback: use the amount we know we bought
if self.extreme_fast_mode and self.extreme_fast_token_amount > 0:
return self.extreme_fast_token_amount
else:
logger.error("Cannot determine tokens received from transaction")
return 0.0
return tokens_received_raw / 10**TOKEN_DECIMALS
+165
View File
@@ -0,0 +1,165 @@
"""
Position management for take profit/stop loss functionality.
"""
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from solders.pubkey import Pubkey
class ExitReason(Enum):
"""Reasons for position exit."""
TAKE_PROFIT = "take_profit"
STOP_LOSS = "stop_loss"
MAX_HOLD_TIME = "max_hold_time"
MANUAL = "manual"
@dataclass
class Position:
"""Represents an active trading position."""
# Token information
mint: Pubkey
symbol: str
# Position details
entry_price: float
quantity: float
entry_time: datetime
# Exit conditions
take_profit_price: float | None = None
stop_loss_price: float | None = None
max_hold_time: int | None = None # seconds
# Status
is_active: bool = True
exit_reason: ExitReason | None = None
exit_price: float | None = None
exit_time: datetime | None = None
@classmethod
def create_from_buy_result(
cls,
mint: Pubkey,
symbol: str,
entry_price: float,
quantity: float,
take_profit_percentage: float | None = None,
stop_loss_percentage: float | None = None,
max_hold_time: int | None = None,
) -> "Position":
"""Create a position from a successful buy transaction.
Args:
mint: Token mint address
symbol: Token symbol
entry_price: Price at which position was entered
quantity: Quantity of tokens purchased
take_profit_percentage: Take profit percentage (0.5 = 50% profit)
stop_loss_percentage: Stop loss percentage (0.2 = 20% loss)
max_hold_time: Maximum hold time in seconds
Returns:
Position instance
"""
take_profit_price = None
if take_profit_percentage is not None:
take_profit_price = entry_price * (1 + take_profit_percentage)
stop_loss_price = None
if stop_loss_percentage is not None:
stop_loss_price = entry_price * (1 - stop_loss_percentage)
return cls(
mint=mint,
symbol=symbol,
entry_price=entry_price,
quantity=quantity,
entry_time=datetime.utcnow(),
take_profit_price=take_profit_price,
stop_loss_price=stop_loss_price,
max_hold_time=max_hold_time,
)
def should_exit(self, current_price: float) -> tuple[bool, ExitReason | None]:
"""Check if position should be exited based on current conditions.
Args:
current_price: Current token price
Returns:
Tuple of (should_exit, exit_reason)
"""
if not self.is_active:
return False, None
# Check take profit
if self.take_profit_price and current_price >= self.take_profit_price:
return True, ExitReason.TAKE_PROFIT
# Check stop loss
if self.stop_loss_price and current_price <= self.stop_loss_price:
return True, ExitReason.STOP_LOSS
# Check max hold time
if self.max_hold_time:
elapsed_time = (datetime.utcnow() - self.entry_time).total_seconds()
if elapsed_time >= self.max_hold_time:
return True, ExitReason.MAX_HOLD_TIME
return False, None
def close_position(self, exit_price: float, exit_reason: ExitReason) -> None:
"""Close the position with exit details.
Args:
exit_price: Price at which position was exited
exit_reason: Reason for exit
"""
self.is_active = False
self.exit_price = exit_price
self.exit_reason = exit_reason
self.exit_time = datetime.utcnow()
def get_pnl(self, current_price: float | None = None) -> dict:
"""Calculate profit/loss for the position.
Args:
current_price: Current price (uses exit_price if position is closed)
Returns:
Dictionary with PnL information
"""
if self.is_active and current_price is None:
raise ValueError("current_price required for active position")
price_to_use = self.exit_price if not self.is_active else current_price
if price_to_use is None:
raise ValueError("No price available for PnL calculation")
price_change = price_to_use - self.entry_price
price_change_pct = (price_change / self.entry_price) * 100
unrealized_pnl = price_change * self.quantity
return {
"entry_price": self.entry_price,
"current_price": price_to_use,
"price_change": price_change,
"price_change_pct": price_change_pct,
"unrealized_pnl_sol": unrealized_pnl,
"quantity": self.quantity,
}
def __str__(self) -> str:
"""String representation of position."""
if self.is_active:
status = "ACTIVE"
elif self.exit_reason:
status = f"CLOSED ({self.exit_reason.value})"
else:
status = "CLOSED (UNKNOWN)"
return f"Position({self.symbol}: {self.quantity:.6f} @ {self.entry_price:.8f} SOL - {status})"
+177 -33
View File
@@ -25,8 +25,10 @@ from core.wallet import Wallet
from monitoring.block_listener import BlockListener from monitoring.block_listener import BlockListener
from monitoring.geyser_listener import GeyserListener from monitoring.geyser_listener import GeyserListener
from monitoring.logs_listener import LogsListener from monitoring.logs_listener import LogsListener
from monitoring.pumpportal_listener import PumpPortalListener
from trading.base import TokenInfo, TradeResult from trading.base import TokenInfo, TradeResult
from trading.buyer import TokenBuyer from trading.buyer import TokenBuyer
from trading.position import Position
from trading.seller import TokenSeller from trading.seller import TokenSeller
from utils.logger import get_logger from utils.logger import get_logger
@@ -49,10 +51,18 @@ class PumpTrader:
geyser_endpoint: str | None = None, geyser_endpoint: str | None = None,
geyser_api_token: str | None = None, geyser_api_token: str | None = None,
geyser_auth_type: str = "x-token", geyser_auth_type: str = "x-token",
pumpportal_url: str = "wss://pumpportal.fun/api/data",
extreme_fast_mode: bool = False, extreme_fast_mode: bool = False,
extreme_fast_token_amount: int = 30, extreme_fast_token_amount: int = 30,
# Exit strategy configuration
exit_strategy: str = "time_based",
take_profit_percentage: float | None = None,
stop_loss_percentage: float | None = None,
max_hold_time: int | None = None,
price_check_interval: int = 10,
# Priority fee configuration # Priority fee configuration
enable_dynamic_priority_fee: bool = False, enable_dynamic_priority_fee: bool = False,
enable_fixed_priority_fee: bool = True, enable_fixed_priority_fee: bool = True,
@@ -88,14 +98,21 @@ class PumpTrader:
buy_slippage: Slippage tolerance for buys buy_slippage: Slippage tolerance for buys
sell_slippage: Slippage tolerance for sells sell_slippage: Slippage tolerance for sells
listener_type: Type of listener to use ('logs', 'blocks', or 'geyser') listener_type: Type of listener to use ('logs', 'blocks', 'geyser', or 'pumpportal')
geyser_endpoint: Geyser endpoint URL (required for geyser listener) geyser_endpoint: Geyser endpoint URL (required for geyser listener)
geyser_api_token: Geyser API token (required for geyser listener) geyser_api_token: Geyser API token (required for geyser listener)
geyser_auth_type: Geyser authentication type ('x-token' or 'basic') geyser_auth_type: Geyser authentication type ('x-token' or 'basic')
pumpportal_url: PumpPortal WebSocket URL (default: wss://pumpportal.fun/api/data)
extreme_fast_mode: Whether to enable extreme fast mode extreme_fast_mode: Whether to enable extreme fast mode
extreme_fast_token_amount: Maximum token amount for extreme fast mode extreme_fast_token_amount: Maximum token amount for extreme fast mode
exit_strategy: Exit strategy ("time_based", "tp_sl", or "manual")
take_profit_percentage: Take profit percentage (0.5 = 50% profit)
stop_loss_percentage: Stop loss percentage (0.2 = 20% loss)
max_hold_time: Maximum hold time in seconds
price_check_interval: How often to check price for TP/SL (seconds)
enable_dynamic_priority_fee: Whether to enable dynamic priority fees enable_dynamic_priority_fee: Whether to enable dynamic priority fees
enable_fixed_priority_fee: Whether to enable fixed priority fees enable_fixed_priority_fee: Whether to enable fixed priority fees
fixed_priority_fee: Fixed priority fee amount fixed_priority_fee: Fixed priority fee amount
@@ -165,6 +182,9 @@ class PumpTrader:
elif listener_type == "logs": elif listener_type == "logs":
self.token_listener = LogsListener(wss_endpoint, PumpAddresses.PROGRAM) self.token_listener = LogsListener(wss_endpoint, PumpAddresses.PROGRAM)
logger.info("Using logsSubscribe listener for token monitoring") logger.info("Using logsSubscribe listener for token monitoring")
elif listener_type == "pumpportal":
self.token_listener = PumpPortalListener(PumpAddresses.PROGRAM, pumpportal_url)
logger.info("Using PumpPortal listener for token monitoring")
else: else:
self.token_listener = BlockListener(wss_endpoint, PumpAddresses.PROGRAM) self.token_listener = BlockListener(wss_endpoint, PumpAddresses.PROGRAM)
logger.info("Using blockSubscribe listener for token monitoring") logger.info("Using blockSubscribe listener for token monitoring")
@@ -177,6 +197,13 @@ class PumpTrader:
self.extreme_fast_mode = extreme_fast_mode self.extreme_fast_mode = extreme_fast_mode
self.extreme_fast_token_amount = extreme_fast_token_amount self.extreme_fast_token_amount = extreme_fast_token_amount
# Exit strategy parameters
self.exit_strategy = exit_strategy.lower()
self.take_profit_percentage = take_profit_percentage
self.stop_loss_percentage = stop_loss_percentage
self.max_hold_time = max_hold_time
self.price_check_interval = price_check_interval
# Timing parameters # Timing parameters
self.wait_time_after_creation = wait_time_after_creation self.wait_time_after_creation = wait_time_after_creation
self.wait_time_after_buy = wait_time_after_buy self.wait_time_after_buy = wait_time_after_buy
@@ -209,6 +236,11 @@ class PumpTrader:
logger.info(f"Creator filter: {self.bro_address if self.bro_address else 'None'}") logger.info(f"Creator filter: {self.bro_address if self.bro_address else 'None'}")
logger.info(f"Marry mode: {self.marry_mode}") logger.info(f"Marry mode: {self.marry_mode}")
logger.info(f"YOLO mode: {self.yolo_mode}") logger.info(f"YOLO mode: {self.yolo_mode}")
logger.info(f"Exit strategy: {self.exit_strategy}")
if self.exit_strategy == "tp_sl":
logger.info(f"Take profit: {self.take_profit_percentage * 100 if self.take_profit_percentage else 'None'}%")
logger.info(f"Stop loss: {self.stop_loss_percentage * 100 if self.stop_loss_percentage else 'None'}%")
logger.info(f"Max hold time: {self.max_hold_time if self.max_hold_time else 'None'} seconds")
logger.info(f"Max token age: {self.max_token_age} seconds") logger.info(f"Max token age: {self.max_token_age} seconds")
try: try:
@@ -439,39 +471,14 @@ class PumpTrader:
) )
self.traded_mints.add(token_info.mint) self.traded_mints.add(token_info.mint)
# Sell token if not in marry mode # Choose exit strategy
if not self.marry_mode: if not self.marry_mode:
logger.info( if self.exit_strategy == "tp_sl":
f"Waiting for {self.wait_time_after_buy} seconds before selling..." await self._handle_tp_sl_exit(token_info, buy_result)
) elif self.exit_strategy == "time_based":
await asyncio.sleep(self.wait_time_after_buy) await self._handle_time_based_exit(token_info)
elif self.exit_strategy == "manual":
logger.info(f"Selling {token_info.symbol}...") logger.info("Manual exit strategy - position will remain open")
sell_result: TradeResult = await self.seller.execute(token_info)
if sell_result.success:
logger.info(f"Successfully sold {token_info.symbol}")
self._log_trade(
"sell",
token_info,
sell_result.price, # type: ignore
sell_result.amount, # type: ignore
sell_result.tx_signature,
)
# Close ATA if enabled
await handle_cleanup_after_sell(
self.solana_client,
self.wallet,
token_info.mint,
self.priority_fee_manager,
self.cleanup_mode,
self.cleanup_with_priority_fee,
self.cleanup_force_close_with_burn
)
else:
logger.error(
f"Failed to sell {token_info.symbol}: {sell_result.error_message}"
)
else: else:
logger.info("Marry mode enabled. Skipping sell operation.") logger.info("Marry mode enabled. Skipping sell operation.")
@@ -498,6 +505,143 @@ class PumpTrader:
self.cleanup_force_close_with_burn self.cleanup_force_close_with_burn
) )
async def _handle_tp_sl_exit(self, token_info: TokenInfo, buy_result: TradeResult) -> None:
"""Handle take profit/stop loss exit strategy.
Args:
token_info: Token information
buy_result: Result from the buy operation
"""
# Create position
position = Position.create_from_buy_result(
mint=token_info.mint,
symbol=token_info.symbol,
entry_price=buy_result.price, # type: ignore
quantity=buy_result.amount, # type: ignore
take_profit_percentage=self.take_profit_percentage,
stop_loss_percentage=self.stop_loss_percentage,
max_hold_time=self.max_hold_time,
)
logger.info(f"Created position: {position}")
if position.take_profit_price:
logger.info(f"Take profit target: {position.take_profit_price:.8f} SOL")
if position.stop_loss_price:
logger.info(f"Stop loss target: {position.stop_loss_price:.8f} SOL")
# Monitor position until exit condition is met
await self._monitor_position_until_exit(token_info, position)
async def _handle_time_based_exit(self, token_info: TokenInfo) -> None:
"""Handle legacy time-based exit strategy.
Args:
token_info: Token information
"""
logger.info(
f"Waiting for {self.wait_time_after_buy} seconds before selling..."
)
await asyncio.sleep(self.wait_time_after_buy)
logger.info(f"Selling {token_info.symbol}...")
sell_result: TradeResult = await self.seller.execute(token_info)
if sell_result.success:
logger.info(f"Successfully sold {token_info.symbol}")
self._log_trade(
"sell",
token_info,
sell_result.price, # type: ignore
sell_result.amount, # type: ignore
sell_result.tx_signature,
)
# Close ATA if enabled
await handle_cleanup_after_sell(
self.solana_client,
self.wallet,
token_info.mint,
self.priority_fee_manager,
self.cleanup_mode,
self.cleanup_with_priority_fee,
self.cleanup_force_close_with_burn
)
else:
logger.error(
f"Failed to sell {token_info.symbol}: {sell_result.error_message}"
)
async def _monitor_position_until_exit(self, token_info: TokenInfo, position: Position) -> None:
"""Monitor a position until exit conditions are met.
Args:
token_info: Token information
position: Position to monitor
"""
logger.info(f"Starting position monitoring (check interval: {self.price_check_interval}s)")
while position.is_active:
try:
# Get current price from bonding curve
current_price = await self.curve_manager.calculate_price(token_info.bonding_curve)
# Check if position should be exited
should_exit, exit_reason = position.should_exit(current_price)
if should_exit and exit_reason:
logger.info(f"Exit condition met: {exit_reason.value}")
logger.info(f"Current price: {current_price:.8f} SOL")
# Log PnL before exit
pnl = position.get_pnl(current_price)
logger.info(f"Position PnL: {pnl['price_change_pct']:.2f}% ({pnl['unrealized_pnl_sol']:.6f} SOL)")
# Execute sell
sell_result = await self.seller.execute(token_info)
if sell_result.success:
# Close position with actual exit price
position.close_position(sell_result.price, exit_reason) # type: ignore
logger.info(f"Successfully exited position: {exit_reason.value}")
self._log_trade(
"sell",
token_info,
sell_result.price, # type: ignore
sell_result.amount, # type: ignore
sell_result.tx_signature,
)
# Log final PnL
final_pnl = position.get_pnl()
logger.info(f"Final PnL: {final_pnl['price_change_pct']:.2f}% ({final_pnl['unrealized_pnl_sol']:.6f} SOL)")
# Close ATA if enabled
await handle_cleanup_after_sell(
self.solana_client,
self.wallet,
token_info.mint,
self.priority_fee_manager,
self.cleanup_mode,
self.cleanup_with_priority_fee,
self.cleanup_force_close_with_burn
)
else:
logger.error(f"Failed to exit position: {sell_result.error_message}")
# Keep monitoring in case sell can be retried
break
else:
# Log current status
pnl = position.get_pnl(current_price)
logger.debug(f"Position status: {current_price:.8f} SOL ({pnl['price_change_pct']:+.2f}%)")
# Wait before next price check
await asyncio.sleep(self.price_check_interval)
except Exception as e:
logger.error(f"Error monitoring position: {e}")
await asyncio.sleep(self.price_check_interval) # Continue monitoring despite errors
async def _save_token_info( async def _save_token_info(
self, token_info: TokenInfo self, token_info: TokenInfo
) -> None: ) -> None: