Reduce RPC delays when selling (#150)

* feat(trading): enhance sell execution with token amount and price parameters to reduce RPC delays

* feat(trading): add method to fetch actual token balance after transaction to improve accuracy

* feat(trading): update account data size limit and enhance price calculations
This commit is contained in:
Anton Sauchyk
2025-11-23 12:02:29 +01:00
committed by GitHub
parent 03a4e7bcbc
commit 9d93d859a8
7 changed files with 293 additions and 60 deletions
+5 -4
View File
@@ -8,7 +8,7 @@ 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
enabled: true # You can turn off the bot w/o removing its config
separate_process: true
# Options: "pump_fun" (default), "lets_bonk"
@@ -31,7 +31,7 @@ trade:
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
max_hold_time: 5 # Maximum hold time in seconds for TP/SL strategy, for time_based - see wait_after_buy
#price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration
@@ -61,8 +61,9 @@ compute_units:
# Reduces CU cost from 16k to ~128 CU by limiting loaded account data.
# Default is 64MB (16k CU). Setting to 512KB significantly reduces overhead.
# Note: Savings don't show in "consumed CU" but improve tx priority/cost.
# Note (Nov 23, 2025): with data size set to 512KB, transactions fail - increasing to 12.5MB resolves the issue.
# Reference: https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit
account_data_size: 512_000 # 512KB limit
account_data_size: 12_500_000
# Filters for token selection
filters:
@@ -77,7 +78,7 @@ filters:
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_after_buy: 5 # Holding period after buy transaction
wait_before_new_token: 15 # Pause between token trades
# Token and account management
+4 -3
View File
@@ -24,14 +24,14 @@ geyser:
# 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.2 # Maximum acceptable price deviation (0.2 = 20%)
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%)
sell_slippage: 0.3
# Exit strategy configuration
exit_strategy: "time_based" # Options: "time_based", "tp_sl", "manual"
take_profit_percentage: 0.4 # Take profit at 40% gain (0.4 = 40%)
stop_loss_percentage: 0.4 # Stop loss at 40% loss (0.4 = 40%)
max_hold_time: 60 # Maximum hold time in seconds
max_hold_time: 15 # Maximum hold time in seconds for TP/SL strategy, for time_based - see wait_after_buy
price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration
@@ -61,8 +61,9 @@ compute_units:
# Reduces CU cost from 16k to ~128 CU by limiting loaded account data.
# Default is 64MB (16k CU). Setting to 512KB significantly reduces overhead.
# Note: Savings don't show in "consumed CU" but improve tx priority/cost.
# Note (Nov 23, 2025): with data size set to 512KB, transactions fail - increasing to 12.5MB resolves the issue.
# Reference: https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit
account_data_size: 512_000 # 512KB limit
account_data_size: 12_500_000
# Filters for token selection
filters:
+4 -3
View File
@@ -8,7 +8,7 @@ rpc_endpoint: "${SOLANA_NODE_RPC_ENDPOINT}"
wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}"
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
# Options: "pump_fun" (default), "lets_bonk"
@@ -31,7 +31,7 @@ trade:
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
max_hold_time: 15 # Maximum hold time in seconds for TP/SL strategy, for time_based - see wait_after_buy
#price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration
@@ -61,8 +61,9 @@ compute_units:
# Reduces CU cost from 16k to ~128 CU by limiting loaded account data.
# Default is 64MB (16k CU). Setting to 512KB significantly reduces overhead.
# Note: Savings don't show in "consumed CU" but improve tx priority/cost.
# Note (Nov 23, 2025): with data size set to 512KB, transactions fail - increasing to 12.5MB resolves the issue.
# Reference: https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit
account_data_size: 512_000 # 512KB limit
account_data_size: 12_500_000
# Filters for token selection
filters:
+3 -2
View File
@@ -29,7 +29,7 @@ trade:
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: 600 # Maximum hold time in seconds (600 = 10 minutes)
max_hold_time: 15 # Maximum hold time in seconds for TP/SL strategy, for time_based - see wait_after_buy
price_check_interval: 2 # Check price every 2 seconds
# EXTREME FAST mode configuration
@@ -59,8 +59,9 @@ compute_units:
# Reduces CU cost from 16k to ~128 CU by limiting loaded account data.
# Default is 64MB (16k CU). Setting to 512KB significantly reduces overhead.
# Note: Savings don't show in "consumed CU" but improve tx priority/cost.
# Note (Nov 23, 2025): with data size set to 512KB, transactions fail - increasing to 12.5MB resolves the issue.
# Reference: https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit
account_data_size: 512_000 # 512KB limit
account_data_size: 12_500_000
# Filters for token selection
filters:
+146
View File
@@ -271,6 +271,152 @@ class SolanaClient:
logger.exception(f"Failed to confirm transaction {signature}")
return False
async def get_transaction_token_balance(
self, signature: str, user_pubkey: Pubkey, mint: Pubkey
) -> int | None:
"""Get the user's token balance after a transaction from postTokenBalances.
Args:
signature: Transaction signature
user_pubkey: User's wallet public key
mint: Token mint address
Returns:
Token balance (raw amount) after transaction, or None if not found
"""
result = await self._get_transaction_result(signature)
if not result:
return None
meta = result.get("meta", {})
post_token_balances = meta.get("postTokenBalances", [])
user_str = str(user_pubkey)
mint_str = str(mint)
for balance in post_token_balances:
if balance.get("owner") == user_str and balance.get("mint") == mint_str:
ui_amount = balance.get("uiTokenAmount", {})
amount_str = ui_amount.get("amount")
if amount_str:
return int(amount_str)
return None
async def get_buy_transaction_details(
self, signature: str, mint: Pubkey, sol_destination: Pubkey
) -> tuple[int | None, int | None]:
"""Get actual tokens received and SOL spent from a buy transaction.
Uses preBalances/postBalances to find exact SOL transferred to the
pool/curve and pre/post token balance diff to find tokens received.
Args:
signature: Transaction signature
mint: Token mint address
sol_destination: Address where SOL is sent (bonding curve for pump.fun,
quote_vault for letsbonk)
Returns:
Tuple of (tokens_received_raw, sol_spent_lamports), or (None, None)
"""
result = await self._get_transaction_result(signature)
if not result:
return None, None
meta = result.get("meta", {})
mint_str = str(mint)
# Get tokens received from pre/post token balance diff
# This works for Token2022 where owner might be different
tokens_received = None
pre_token_balances = meta.get("preTokenBalances", [])
post_token_balances = meta.get("postTokenBalances", [])
# Build lookup by account index
pre_by_idx = {b.get("accountIndex"): b for b in pre_token_balances}
post_by_idx = {b.get("accountIndex"): b for b in post_token_balances}
# Find positive token diff for our mint (user receiving tokens)
all_indices = set(pre_by_idx.keys()) | set(post_by_idx.keys())
for idx in all_indices:
pre = pre_by_idx.get(idx)
post = post_by_idx.get(idx)
# Check if this is our mint
balance_mint = (post or pre).get("mint", "")
if balance_mint != mint_str:
continue
pre_amount = (
int(pre.get("uiTokenAmount", {}).get("amount", 0)) if pre else 0
)
post_amount = (
int(post.get("uiTokenAmount", {}).get("amount", 0)) if post else 0
)
diff = post_amount - pre_amount
# Positive diff means tokens received (not the bonding curve's negative)
if diff > 0:
tokens_received = diff
logger.info(f"Tokens received from tx: {tokens_received}")
break
# Get SOL spent from preBalances/postBalances at sol_destination
sol_destination_str = str(sol_destination)
sol_spent = None
pre_balances = meta.get("preBalances", [])
post_balances = meta.get("postBalances", [])
account_keys = (
result.get("transaction", {}).get("message", {}).get("accountKeys", [])
)
for i, key in enumerate(account_keys):
key_str = key if isinstance(key, str) else key.get("pubkey", "")
if key_str == sol_destination_str:
if i < len(pre_balances) and i < len(post_balances):
sol_spent = post_balances[i] - pre_balances[i]
if sol_spent > 0:
logger.info(f"SOL to pool/curve: {sol_spent} lamports")
else:
logger.warning(
f"SOL destination balance change not positive: {sol_spent}"
)
sol_spent = None
break
return tokens_received, sol_spent
async def _get_transaction_result(self, signature: str) -> dict | None:
"""Fetch transaction result from RPC.
Args:
signature: Transaction signature
Returns:
Transaction result dict or None
"""
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "getTransaction",
"params": [
signature,
{"encoding": "jsonParsed", "commitment": "confirmed"},
],
}
response = await self.post_rpc(body)
if not response or "result" not in response:
logger.warning(f"Failed to get transaction {signature}")
return None
result = response["result"]
if not result or "meta" not in result:
return None
return result
async def post_rpc(self, body: dict[str, Any]) -> dict[str, Any] | None:
"""
Send a raw RPC request to the Solana node.
+107 -39
View File
@@ -130,6 +130,35 @@ class PlatformAwareBuyer(Trader):
if success:
logger.info(f"Buy transaction confirmed: {tx_signature}")
# Fetch actual tokens and SOL spent from transaction
# Uses preBalances/postBalances to get exact amounts
sol_destination = self._get_sol_destination(
token_info, address_provider
)
tokens_raw, sol_spent = await self.client.get_buy_transaction_details(
str(tx_signature), token_info.mint, sol_destination
)
if tokens_raw is not None and sol_spent is not None:
actual_amount = tokens_raw / 10**TOKEN_DECIMALS
actual_price = (sol_spent / LAMPORTS_PER_SOL) / actual_amount
logger.info(
f"Actual tokens received: {actual_amount:.6f} "
f"(expected: {token_amount:.6f})"
)
logger.info(
f"Actual SOL spent: {sol_spent / LAMPORTS_PER_SOL:.10f} SOL"
)
logger.info(f"Actual price: {actual_price:.10f} SOL/token")
token_amount = actual_amount
token_price_sol = actual_price
else:
raise ValueError(
f"Failed to parse transaction details: tokens={tokens_raw}, "
f"sol_spent={sol_spent}"
)
return TradeResult(
success=True,
platform=token_info.platform,
@@ -165,6 +194,42 @@ class PlatformAwareBuyer(Trader):
# Fallback to deriving the address using platform provider
return address_provider.derive_pool_address(token_info.mint)
def _get_sol_destination(
self, token_info: TokenInfo, address_provider: AddressProvider
) -> Pubkey:
"""Get the address where SOL is sent during a buy transaction.
For pump.fun: SOL goes to the bonding curve
For letsbonk: SOL goes to the quote_vault (WSOL vault)
Args:
token_info: Token information
address_provider: Platform-specific address provider
Returns:
Address where SOL is transferred during buy
Raises:
NotImplementedError: If platform SOL destination is not implemented
"""
if token_info.platform == Platform.PUMP_FUN:
# For pump.fun, SOL goes directly to bonding curve
if hasattr(token_info, "bonding_curve") and token_info.bonding_curve:
return token_info.bonding_curve
return address_provider.derive_pool_address(token_info.mint)
elif token_info.platform == Platform.LETS_BONK:
# For letsbonk, SOL goes to quote_vault (WSOL vault)
if hasattr(token_info, "quote_vault") and token_info.quote_vault:
return token_info.quote_vault
# Derive quote_vault if not available
return address_provider.derive_quote_vault(token_info.mint)
raise NotImplementedError(
f"SOL destination not implemented for platform {token_info.platform.value}. "
f"Add platform-specific logic to _get_sol_destination() to specify where "
f"SOL is transferred during buy transactions for this platform."
)
def _get_cu_override(self, operation: str, platform: Platform) -> int | None:
"""Get compute unit override from configuration.
@@ -202,8 +267,35 @@ class PlatformAwareSeller(Trader):
self.max_retries = max_retries
self.compute_units = compute_units or {}
async def execute(self, token_info: TokenInfo) -> TradeResult:
"""Execute sell operation using platform-specific implementations."""
async def execute(
self, token_info: TokenInfo, token_amount: float, token_price: float
) -> TradeResult:
"""Execute sell operation using platform-specific implementations.
Args:
token_info: Token information for the sell operation
token_amount: Token amount to sell (from buy result). Required to avoid
RPC balance query delays.
token_price: Token price in SOL (from buy result). Required to avoid
RPC pool state query delays.
Returns:
TradeResult with operation outcome
Raises:
ValueError: If required parameters are not provided
"""
if token_amount is None:
raise ValueError(
"token_amount is required for sell operation. "
"Pass the amount from buy result to avoid RPC delays."
)
if token_price is None or token_price <= 0:
raise ValueError(
"token_price is required for sell operation and must be positive. "
"Pass the price from buy result to avoid RPC delays."
)
try:
# Get platform-specific implementations
implementations = get_platform_implementations(
@@ -211,19 +303,14 @@ class PlatformAwareSeller(Trader):
)
address_provider = implementations.address_provider
instruction_builder = implementations.instruction_builder
curve_manager = implementations.curve_manager
# Get user's token account and balance
user_token_account = address_provider.derive_user_token_account(
self.wallet.pubkey, token_info.mint
)
# Use pre-known amount and price (no RPC delay)
token_balance_decimal = token_amount
token_balance = int(token_amount * 10**TOKEN_DECIMALS)
token_price_sol = token_price
token_balance = await self.client.get_token_account_balance(
user_token_account
)
token_balance_decimal = token_balance / 10**TOKEN_DECIMALS
logger.info(f"Token balance: {token_balance_decimal}")
logger.info(f"Token balance: {token_balance_decimal:.6f}")
logger.info(f"Price per Token (from buy): {token_price_sol:.8f} SOL")
if token_balance == 0:
logger.info("No tokens to sell.")
@@ -233,38 +320,19 @@ class PlatformAwareSeller(Trader):
error_message="No tokens to sell",
)
# Get pool address and current price using platform-agnostic method
pool_address = self._get_pool_address(token_info, address_provider)
# Fetch pool state to get price and mayhem mode status
pool_state = await curve_manager.get_pool_state(pool_address)
token_price_sol = pool_state.get("price_per_token")
# Validate price_per_token is present and positive
if token_price_sol is None or token_price_sol <= 0:
raise ValueError(
f"Invalid price_per_token: {token_price_sol} for pool {pool_address} "
f"(mint: {token_info.mint}) - cannot execute sell with zero/invalid price"
)
# Set is_mayhem_mode from bonding curve state
token_info.is_mayhem_mode = pool_state.get("is_mayhem_mode", False)
logger.info(f"Price per Token: {token_price_sol:.8f} SOL")
# Calculate expected SOL output
# Calculate expected SOL output with slippage protection
expected_sol_output = token_balance_decimal * token_price_sol
# Calculate minimum SOL output with slippage protection
min_sol_output = int(
(expected_sol_output * (1 - self.slippage)) * LAMPORTS_PER_SOL
min_sol_output = max(
1,
int((expected_sol_output * (1 - self.slippage)) * LAMPORTS_PER_SOL),
)
logger.info(
f"Selling {token_balance_decimal} tokens on {token_info.platform.value}"
)
logger.info(f"Expected SOL output: {expected_sol_output:.8f} SOL")
logger.info(f"Expected SOL output: {expected_sol_output:.10f} SOL")
logger.info(
f"Minimum SOL output (with {self.slippage * 100:.1f}% slippage): {min_sol_output / LAMPORTS_PER_SOL:.8f} SOL"
f"Minimum SOL output (with {self.slippage * 100:.1f}% slippage): "
f"{min_sol_output / LAMPORTS_PER_SOL:.10f} SOL ({min_sol_output} lamports)"
)
# Build sell instructions using platform-specific builder
+24 -9
View File
@@ -193,7 +193,9 @@ class UniversalTrader:
# State tracking
self.traded_mints: set[Pubkey] = set()
self.traded_token_programs: dict[str, Pubkey] = {} # Maps mint (as string) to token_program_id
self.traded_token_programs: dict[
str, Pubkey
] = {} # Maps mint (as string) to token_program_id
self.token_queue: asyncio.Queue = asyncio.Queue()
self.processing: bool = False
self.processed_tokens: set[str] = set()
@@ -329,8 +331,7 @@ class UniversalTrader:
# Build parallel lists of mints and token_program_ids
mints_list = list(self.traded_mints)
token_program_ids = [
self.traded_token_programs.get(str(mint))
for mint in mints_list
self.traded_token_programs.get(str(mint)) for mint in mints_list
]
await handle_cleanup_post_session(
self.solana_client,
@@ -465,7 +466,7 @@ class UniversalTrader:
if self.exit_strategy == "tp_sl":
await self._handle_tp_sl_exit(token_info, buy_result)
elif self.exit_strategy == "time_based":
await self._handle_time_based_exit(token_info)
await self._handle_time_based_exit(token_info, buy_result)
elif self.exit_strategy == "manual":
logger.info("Manual exit strategy - position will remain open")
else:
@@ -512,13 +513,23 @@ class UniversalTrader:
# 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."""
async def _handle_time_based_exit(
self, token_info: TokenInfo, buy_result: TradeResult
) -> None:
"""Handle legacy time-based exit strategy.
Args:
token_info: Token information
buy_result: Result from the buy operation (contains token amount)
"""
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)
# Pass token amount and price from buy result to avoid RPC delays
sell_result: TradeResult = await self.seller.execute(
token_info, token_amount=buy_result.amount, token_price=buy_result.price
)
if sell_result.success:
logger.info(f"Successfully sold {token_info.symbol}")
@@ -575,8 +586,12 @@ class UniversalTrader:
f"Position PnL: {pnl['price_change_pct']:.2f}% ({pnl['unrealized_pnl_sol']:.6f} SOL)"
)
# Execute sell
sell_result = await self.seller.execute(token_info)
# Execute sell with position quantity and entry price to avoid RPC delays
sell_result = await self.seller.execute(
token_info,
token_amount=position.quantity,
token_price=position.entry_price,
)
if sell_result.success:
# Close position with actual exit price