diff --git a/CLAUDE.md b/CLAUDE.md index 7449c68..2fff135 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,6 +191,36 @@ with `BuybackFeeRecipientMissing` (6062) printed as confirmed buys. and `str()` on it is empty, so the caller logs a blank reason. A slow `getAccountInfo` is enough to take down a whole listener run this way. +### Verifying the tp/sl exit path (issue #189) + +```bash +# Offline: the exit sell prices off the price that triggered it, a reverted +# exit sell is retried, and the retry is bounded +uv run learning-examples/verify_tp_sl_exit_price.py +``` + +`PlatformAwareSeller.execute` does not read a price — the `token_price` it is +handed **is** the slippage floor (`min_quote_output = amount * price * +(1 - slippage)`). So the caller owns the floor's correctness. A tp/sl exit fires +precisely because price left `entry_price`, so pricing the sell off the entry +sets a floor the pool cannot pay on a stop-loss and the sell reverts with 6003 +`TooLittleSolReceived` — during the drop the stop-loss exists to escape. On a +take-profit the same mistake runs the other way and the floor protects nothing. +`_monitor_position_until_exit` already fetches `current_price` at the top of +each iteration, so passing it costs no extra RPC call; `_handle_time_based_exit` +genuinely has nothing fresher and keeps passing the buy price. + +The seller's `max_retries` covers **transaction submission only**. An on-chain +revert comes back as `success=False` and is not retried there, so the retry has +to happen in the monitor loop, where the price is re-read first. +`trade.max_exit_sell_attempts` (default 3, validated to 1..100) bounds it so a +token that keeps reverting cannot pin the bot on one position, and the counter +resets if the price recovers out of the exit band. After the last attempt the +position is left open and unmonitored — logged loudly, since the tokens are +still held. Watch the `break`: before #189 it sat outside both branches of +`if sell_result.success:`, so a failed sell abandoned the position after a +single try while leaving `is_active=True`. + ### Listener and decoder pitfalls Each of these was a live bug in `learning-examples/`, all of them invisible diff --git a/bots/bot-sniper-1-geyser.yaml b/bots/bot-sniper-1-geyser.yaml index 5b77eb2..678adfc 100644 --- a/bots/bot-sniper-1-geyser.yaml +++ b/bots/bot-sniper-1-geyser.yaml @@ -43,6 +43,11 @@ trade: #stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%) 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 + # A tp/sl exit sell that reverts on-chain (slippage, curve moved under it) is + # retried on the next price check, re-reading the price first so the slippage + # floor matches the market. After this many attempts the bot gives up and + # leaves the position open - tokens are still held. + #max_exit_sell_attempts: 3 # EXTREME FAST mode configuration # When enabled, skips waiting for the bonding curve to stabilize and RPC price check. diff --git a/bots/bot-sniper-2-logs.yaml b/bots/bot-sniper-2-logs.yaml index 52bcea5..f88cb85 100644 --- a/bots/bot-sniper-2-logs.yaml +++ b/bots/bot-sniper-2-logs.yaml @@ -33,6 +33,11 @@ trade: stop_loss_percentage: 0.4 # Stop loss at 40% loss (0.4 = 40%) 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 + # A tp/sl exit sell that reverts on-chain (slippage, curve moved under it) is + # retried on the next price check, re-reading the price first so the slippage + # floor matches the market. After this many attempts the bot gives up and + # leaves the position open - tokens are still held. + max_exit_sell_attempts: 3 # EXTREME FAST mode configuration # When enabled, skips waiting for the bonding curve to stabilize and RPC price check. diff --git a/bots/bot-sniper-3-blocks.yaml b/bots/bot-sniper-3-blocks.yaml index 351875c..886fb25 100644 --- a/bots/bot-sniper-3-blocks.yaml +++ b/bots/bot-sniper-3-blocks.yaml @@ -33,6 +33,11 @@ trade: #stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%) 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 + # A tp/sl exit sell that reverts on-chain (slippage, curve moved under it) is + # retried on the next price check, re-reading the price first so the slippage + # floor matches the market. After this many attempts the bot gives up and + # leaves the position open - tokens are still held. + #max_exit_sell_attempts: 3 # EXTREME FAST mode configuration # When enabled, skips waiting for the bonding curve to stabilize and RPC price check. diff --git a/bots/bot-sniper-4-pp.yaml b/bots/bot-sniper-4-pp.yaml index 8b38339..a143f98 100644 --- a/bots/bot-sniper-4-pp.yaml +++ b/bots/bot-sniper-4-pp.yaml @@ -31,6 +31,11 @@ trade: stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%) 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 + # A tp/sl exit sell that reverts on-chain (slippage, curve moved under it) is + # retried on the next price check, re-reading the price first so the slippage + # floor matches the market. After this many attempts the bot gives up and + # leaves the position open - tokens are still held. + max_exit_sell_attempts: 3 # EXTREME FAST mode configuration # When enabled, skips waiting for the bonding curve to stabilize and RPC price check. diff --git a/learning-examples/verify_tp_sl_exit_price.py b/learning-examples/verify_tp_sl_exit_price.py new file mode 100644 index 0000000..d4bb476 --- /dev/null +++ b/learning-examples/verify_tp_sl_exit_price.py @@ -0,0 +1,438 @@ +"""Verify the tp/sl exit sells against the fresh price and retries a failed sell. + +Two bugs in `UniversalTrader._monitor_position_until_exit` (issue #189): + + 1. The sell was handed `position.entry_price` while the `current_price` that + had just triggered the exit sat in the same scope, fetched one RPC call + earlier. The seller turns that price into the slippage floor + (`min_quote_output`), so on a stop-loss the floor was computed from the + higher entry price and demanded more quote asset than the curve could pay + — the sell reverts with pump.fun 6003 TooLittleSolReceived exactly during + the drop the stop-loss exists to escape. On a take-profit the error runs + the other way: the floor lands far below market and protects nothing. + + 2. `break` sat outside both branches of `if sell_result.success:`, so the loop + exited whether the sell landed or not, contradicting the + "Keep monitoring in case sell can be retried" comment right above it. The + seller's own `max_retries` covers transaction *submission* only, so an + on-chain revert was never retried: the position was abandoned mid-crash. + +Offline machine checks, no network and no funds moved. The real monitor loop is +driven with a stub curve manager serving a scripted price series and a stub +seller that records the price it is handed: + + 1. A stop-loss exit passes the triggering price, not the entry price. + 2. A take-profit exit passes the triggering price too. + 3. The floor built from the entry price is unpayable on a stop-loss, while + the floor from the triggering price is payable (why check 1 matters). + 4. A failed sell is retried, and a retry that succeeds closes the position. + 5. Retries are bounded, so a token that keeps reverting cannot pin the bot. + 6. A price that recovers before the retry resets the attempt counter. + 7. A successful sell still closes the position on the first attempt. + 8. The cap comes from trade.max_exit_sell_attempts and is wired end to end. + +Usage: + uv run learning-examples/verify_tp_sl_exit_price.py +""" + +import asyncio +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import SimpleNamespace + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +from solders.pubkey import Pubkey # noqa: E402 + +from core.pubkeys import WSOL_MINT, quote_units_per_token # noqa: E402 +from interfaces.core import Platform, TokenInfo # noqa: E402 +from trading.base import TradeResult # noqa: E402 +from trading.position import Position # noqa: E402 +from trading.universal_trader import ( # noqa: E402 + DEFAULT_MAX_EXIT_SELL_ATTEMPTS, + UniversalTrader, +) + +ENTRY_PRICE = 1.0e-6 # SOL per token +QUANTITY = 1_000_000.0 # tokens, so entry notional is 1.0 SOL +SELL_SLIPPAGE = 0.3 # bots/*.yaml default +STOP_LOSS_PCT = 0.4 +TAKE_PROFIT_PCT = 0.4 + +SL_TRIGGER_PRICE = ENTRY_PRICE * 0.55 # 45% down, past the stop loss +TP_TRIGGER_PRICE = ENTRY_PRICE * 1.5 # 50% up, past the take profit + +REVERT_6003 = "custom program error: 0x1773 (6003 TooLittleSolReceived)" + + +@dataclass +class StubCurveManager: + """Serves a scripted price series; the last value repeats forever.""" + + prices: list[float] + calls: int = 0 + + async def calculate_price(self, _pool_address: Pubkey) -> float: + price = self.prices[min(self.calls, len(self.prices) - 1)] + self.calls += 1 + return price + + +@dataclass +class StubSeller: + """Records the price it is handed. Fails the first `fail_first` calls.""" + + fail_first: int = 0 + prices_seen: list[float] = field(default_factory=list) + + async def execute( + self, token_info: TokenInfo, token_amount: float, token_price: float + ) -> TradeResult: + self.prices_seen.append(token_price) + if len(self.prices_seen) <= self.fail_first: + return TradeResult( + success=False, + platform=token_info.platform, + error_message=REVERT_6003, + ) + return TradeResult( + success=True, + platform=token_info.platform, + tx_signature="stub-signature", + amount=token_amount, + price=token_price, + ) + + +def _make_trader( + curve_manager: StubCurveManager, + seller: StubSeller, + max_exit_sell_attempts: int = DEFAULT_MAX_EXIT_SELL_ATTEMPTS, +) -> UniversalTrader: + """Build a trader carrying only what the monitor loop touches.""" + trader = object.__new__(UniversalTrader) + trader.price_check_interval = 0 # no real waiting between iterations + trader.max_exit_sell_attempts = max_exit_sell_attempts + trader.platform_implementations = SimpleNamespace( + curve_manager=curve_manager, address_provider=None + ) + trader.seller = seller + trader.solana_client = None + trader.wallet = None + trader.priority_fee_manager = None + trader.cleanup_mode = "disabled" # keeps handle_cleanup_after_sell a no-op + trader.cleanup_with_priority_fee = False + trader.cleanup_force_close_with_burn = False + # Keep a verification run from writing to ./trades. + trader._log_trade = lambda *_args, **_kwargs: None # noqa: SLF001 + return trader + + +def _make_token_info() -> TokenInfo: + return TokenInfo( + name="Verify189", + symbol="V189", + uri="", + mint=Pubkey.default(), + platform=Platform.PUMP_FUN, + bonding_curve=Pubkey.default(), + ) + + +def _make_position() -> Position: + return Position.create_from_buy_result( + mint=Pubkey.default(), + symbol="V189", + entry_price=ENTRY_PRICE, + quantity=QUANTITY, + take_profit_percentage=TAKE_PROFIT_PCT, + stop_loss_percentage=STOP_LOSS_PCT, + max_hold_time=None, + ) + + +def _slippage_floor(reference_price: float) -> int: + """Reproduce the seller's min_quote_output for the fixture position. + + Mirrors PlatformAwareSeller.execute: expected output is amount * price, + then the slippage tolerance comes off it, in the quote mint's raw units. + """ + expected_quote_output = QUANTITY * reference_price + return max( + 1, + int( + (expected_quote_output * (1 - SELL_SLIPPAGE)) + * quote_units_per_token(WSOL_MINT) + ), + ) + + +def _payable(price: float) -> int: + """Raw quote units the pool would return at `price`, ignoring curve impact. + + Optimistic on purpose: a real sell moves the curve down and pays a fee, so + anything unpayable against this number is unpayable on chain too. + """ + return int(QUANTITY * price * quote_units_per_token(WSOL_MINT)) + + +MONITOR_TIMEOUT = 10 # a bounded loop finishes in milliseconds here + + +async def _run_monitor( + prices: list[float], + fail_first: int = 0, + max_exit_sell_attempts: int = DEFAULT_MAX_EXIT_SELL_ATTEMPTS, +) -> tuple[Position, StubSeller, StubCurveManager]: + """Drive the real monitor loop to completion over a scripted price series. + + Raises: + TimeoutError: If the loop never exits, i.e. retries are unbounded. + """ + curve_manager = StubCurveManager(prices=list(prices)) + seller = StubSeller(fail_first=fail_first) + position = _make_position() + trader = _make_trader(curve_manager, seller, max_exit_sell_attempts) + await asyncio.wait_for( + trader._monitor_position_until_exit(_make_token_info(), position), # noqa: SLF001 + timeout=MONITOR_TIMEOUT, + ) + return position, seller, curve_manager + + +def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001 + print(f" [{'PASS' if passed else 'FAIL'}] {label}: {detail}") + return passed + + +async def check_stop_loss_uses_trigger_price() -> bool: + print("\n1. Stop-loss exit sells against the triggering price") + _, seller, _ = await _run_monitor([ENTRY_PRICE, SL_TRIGGER_PRICE]) + price = seller.prices_seen[0] + return _check( + "price handed to seller", + price == SL_TRIGGER_PRICE, + f"{price:.8f} SOL (trigger {SL_TRIGGER_PRICE:.8f}, entry {ENTRY_PRICE:.8f})", + ) + + +async def check_take_profit_uses_trigger_price() -> bool: + print("\n2. Take-profit exit sells against the triggering price") + _, seller, _ = await _run_monitor([ENTRY_PRICE, TP_TRIGGER_PRICE]) + price = seller.prices_seen[0] + return _check( + "price handed to seller", + price == TP_TRIGGER_PRICE, + f"{price:.8f} SOL (trigger {TP_TRIGGER_PRICE:.8f}, entry {ENTRY_PRICE:.8f})", + ) + + +def check_stale_floor_is_unpayable() -> bool: + print("\n3. Why it matters: the entry-price floor is unpayable on a drop") + from_entry = _slippage_floor(ENTRY_PRICE) + from_trigger = _slippage_floor(SL_TRIGGER_PRICE) + payable = _payable(SL_TRIGGER_PRICE) + print( + f" floor from entry price : {from_entry:>14,} raw quote units\n" + f" floor from trigger price : {from_trigger:>14,}\n" + f" pool can pay (optimistic): {payable:>14,}" + ) + ok = _check( + "entry-price floor exceeds what the pool can pay", + from_entry > payable, + f"{from_entry:,} > {payable:,}, so the sell reverts 6003", + ) + return ok and _check( + "trigger-price floor is payable", + from_trigger <= payable, + f"{from_trigger:,} <= {payable:,}", + ) + + +async def check_failed_sell_is_retried() -> bool: + print("\n4. A failed sell is retried on the next price check") + fail_first = 1 + position, seller, _ = await _run_monitor( + [ENTRY_PRICE, SL_TRIGGER_PRICE], fail_first=fail_first + ) + expected = fail_first + 1 # the failure, then the retry that lands + ok = _check( + "seller called again after the failure", + len(seller.prices_seen) == expected, + f"{len(seller.prices_seen)} attempts, expected {expected}", + ) + ok = ( + _check( + "position closed after the retry landed", + not position.is_active, + f"is_active={position.is_active}, " + f"exit_reason={position.exit_reason.value if position.exit_reason else None}", + ) + and ok + ) + return ok + + +async def check_retries_are_bounded() -> bool: + print("\n5. Retries are bounded, so a reverting token cannot pin the bot") + # fail_first far above the cap: the loop must give up on its own, so the + # timeout firing is itself a failure - it means the retry never terminates + # and the bot would sit on this position forever. + try: + position, seller, _ = await _run_monitor( + [ENTRY_PRICE, SL_TRIGGER_PRICE], fail_first=99 + ) + except TimeoutError: + return _check( + "monitor loop terminates on repeated failures", + False, # noqa: FBT003 + f"still retrying after {MONITOR_TIMEOUT}s - retries are unbounded", + ) + ok = _check( + "attempts capped at the configured maximum", + len(seller.prices_seen) == DEFAULT_MAX_EXIT_SELL_ATTEMPTS, + f"{len(seller.prices_seen)} attempts, cap {DEFAULT_MAX_EXIT_SELL_ATTEMPTS}", + ) + ok = ( + _check( + "position not falsely marked closed", + position.is_active and position.exit_price is None, + f"is_active={position.is_active}, exit_price={position.exit_price}", + ) + and ok + ) + # Every retry must re-read the price rather than reuse the first one. + ok = ( + _check( + "every attempt used the freshly read price", + all(p == SL_TRIGGER_PRICE for p in seller.prices_seen), + f"prices seen: {[f'{p:.8f}' for p in seller.prices_seen]}", + ) + and ok + ) + return ok + + +async def check_recovery_resets_attempts() -> bool: + print("\n6. A price recovery between attempts resets the attempt counter") + # Fail every sell. The price dips below the stop loss, recovers to flat, + # then dips again. With the counter reset on recovery, the cap applies to + # each burst separately, so the total is one full cap plus the earlier dip. + prices = [ENTRY_PRICE, SL_TRIGGER_PRICE, ENTRY_PRICE, SL_TRIGGER_PRICE] + _, seller, _ = await _run_monitor(prices, fail_first=99) + expected = 1 + DEFAULT_MAX_EXIT_SELL_ATTEMPTS + return _check( + "attempts counted per burst, not per position", + len(seller.prices_seen) == expected, + f"{len(seller.prices_seen)} attempts (1 before recovery + " + f"{DEFAULT_MAX_EXIT_SELL_ATTEMPTS} after), expected {expected}", + ) + + +async def check_successful_sell_closes_once() -> bool: + print("\n7. A successful sell still closes the position on the first attempt") + position, seller, _ = await _run_monitor([ENTRY_PRICE, TP_TRIGGER_PRICE]) + ok = _check( + "single sell attempt", + len(seller.prices_seen) == 1, + f"{len(seller.prices_seen)} attempt", + ) + return ( + _check( + "position closed with the exit recorded", + not position.is_active + and position.exit_reason is not None + and position.exit_price == TP_TRIGGER_PRICE, + f"is_active={position.is_active}, " + f"reason={position.exit_reason.value if position.exit_reason else None}, " + f"exit_price={position.exit_price}", + ) + and ok + ) + + +async def check_config_knob_is_honoured() -> bool: + """The cap comes from trade.max_exit_sell_attempts, not a hardcoded value.""" + print("\n8. trade.max_exit_sell_attempts drives the cap") + configured = 2 # deliberately different from the default + _, seller, _ = await _run_monitor( + [ENTRY_PRICE, SL_TRIGGER_PRICE], + fail_first=99, + max_exit_sell_attempts=configured, + ) + ok = _check( + "configured value overrides the default", + len(seller.prices_seen) == configured != DEFAULT_MAX_EXIT_SELL_ATTEMPTS, + f"{len(seller.prices_seen)} attempts with the knob set to {configured} " + f"(default {DEFAULT_MAX_EXIT_SELL_ATTEMPTS})", + ) + + # Static wiring: a knob the runner never reads is a knob that does nothing. + runner = (PROJECT_ROOT / "src" / "bot_runner.py").read_text() + ok = ( + _check( + "bot_runner reads it from the trade config", + 'cfg["trade"].get(' in runner + and "max_exit_sell_attempts" in runner + and "DEFAULT_MAX_EXIT_SELL_ATTEMPTS" in runner, + "passed to UniversalTrader with the module default as fallback", + ) + and ok + ) + loader = (PROJECT_ROOT / "src" / "config_loader.py").read_text() + ok = ( + _check( + "config_loader validates its range", + "trade.max_exit_sell_attempts" in loader, + "a 0 or a string in the YAML is rejected at startup", + ) + and ok + ) + documented = sorted( + path.name + for path in (PROJECT_ROOT / "bots").glob("*.yaml") + if "max_exit_sell_attempts" in path.read_text() + ) + bots = sorted(path.name for path in (PROJECT_ROOT / "bots").glob("*.yaml")) + return ( + _check( + "every bot config documents it", + documented == bots, + f"{len(documented)}/{len(bots)} configs mention it", + ) + and ok + ) + + +async def main() -> int: + print("Verifying tp/sl exit pricing and retry behaviour (issue #189)") + print( + f"fixture: entry {ENTRY_PRICE:.8f} SOL, {QUANTITY:,.0f} tokens, " + f"SL -{STOP_LOSS_PCT:.0%}, TP +{TAKE_PROFIT_PCT:.0%}, " + f"sell slippage {SELL_SLIPPAGE:.0%}" + ) + + results = [ + await check_stop_loss_uses_trigger_price(), + await check_take_profit_uses_trigger_price(), + check_stale_floor_is_unpayable(), + await check_failed_sell_is_retried(), + await check_retries_are_bounded(), + await check_recovery_resets_attempts(), + await check_successful_sell_closes_once(), + await check_config_knob_is_honoured(), + ] + + passed = sum(results) + print(f"\n{passed}/{len(results)} checks passed") + if passed != len(results): + print("FAILED") + return 1 + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/bot_runner.py b/src/bot_runner.py index 2415624..c4eb549 100644 --- a/src/bot_runner.py +++ b/src/bot_runner.py @@ -29,7 +29,10 @@ from config_loader import ( print_config_summary, validate_platform_listener_combination, ) -from trading.universal_trader import UniversalTrader +from trading.universal_trader import ( + DEFAULT_MAX_EXIT_SELL_ATTEMPTS, + UniversalTrader, +) from utils.logger import setup_file_logging @@ -109,6 +112,9 @@ async def start_bot(config_path: str): 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), + max_exit_sell_attempts=cfg["trade"].get( + "max_exit_sell_attempts", DEFAULT_MAX_EXIT_SELL_ATTEMPTS + ), # Listener configuration listener_type=cfg["filters"]["listener_type"], # Geyser configuration (if applicable) diff --git a/src/config_loader.py b/src/config_loader.py index 8719336..8427e96 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -34,6 +34,13 @@ CONFIG_VALIDATION_RULES = [ ), ("trade.buy_slippage", float, 0, 1, "trade.buy_slippage must be between 0 and 1"), ("trade.sell_slippage", float, 0, 1, "trade.sell_slippage must be between 0 and 1"), + ( + "trade.max_exit_sell_attempts", + int, + 1, + 100, + "trade.max_exit_sell_attempts must be between 1 and 100", + ), ( "priority_fees.fixed_amount", int, diff --git a/src/trading/platform_aware.py b/src/trading/platform_aware.py index e32ae45..f2b55e8 100644 --- a/src/trading/platform_aware.py +++ b/src/trading/platform_aware.py @@ -610,8 +610,12 @@ class PlatformAwareSeller(Trader): 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. + token_price: Reference price in the quote asset that the slippage + floor is computed from. Required rather than read here, + to avoid RPC pool state query delays — pass the freshest + price the caller has. A stale price that is above the + market sets a floor the pool cannot pay and the sell + reverts (pump.fun 6003 TooLittleSolReceived). Returns: TradeResult with operation outcome @@ -698,7 +702,7 @@ class PlatformAwareSeller(Trader): logger.info(f"Token balance: {token_balance_decimal:.6f}") logger.info( - f"Price per Token (from buy): {token_price_sol:.8f} {quote_label}" + f"Reference price per token: {token_price_sol:.8f} {quote_label}" ) if token_balance == 0: diff --git a/src/trading/universal_trader.py b/src/trading/universal_trader.py index 35470bf..d331ec3 100644 --- a/src/trading/universal_trader.py +++ b/src/trading/universal_trader.py @@ -51,6 +51,14 @@ except ImportError: logger = get_logger(__name__) +# Default for trade.max_exit_sell_attempts: how many times a tp/sl exit sell is +# re-attempted before the position is left open. A revert (slippage, curve +# moved) is not retried by the seller itself — its max_retries only covers +# transaction submission — so the retry has to happen in the monitor loop, +# where the price is re-read first. Bounded so a token that keeps reverting +# cannot pin the bot on one position forever. +DEFAULT_MAX_EXIT_SELL_ATTEMPTS = 3 + def _resolve_quote_config( buy_amount: float, @@ -114,6 +122,7 @@ class UniversalTrader: stop_loss_percentage: float | None = None, max_hold_time: int | None = None, price_check_interval: int = 10, + max_exit_sell_attempts: int = DEFAULT_MAX_EXIT_SELL_ATTEMPTS, # Priority fee configuration enable_dynamic_priority_fee: bool = False, enable_fixed_priority_fee: bool = True, @@ -233,7 +242,12 @@ class UniversalTrader: 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 + # Both govern the position monitor loop. The attempt cap is clamped + # because a value below 1 would mean "never even try to sell". + self.price_check_interval, self.max_exit_sell_attempts = ( + price_check_interval, + max(1, max_exit_sell_attempts), + ) # Timing parameters self.wait_time_after_creation = wait_time_after_creation @@ -286,6 +300,7 @@ class UniversalTrader: logger.info( f"Max hold time: {self.max_hold_time if self.max_hold_time else 'None'} seconds" ) + logger.info(f"Max exit sell attempts: {self.max_exit_sell_attempts}") logger.info(f"Max token age: {self.max_token_age} seconds") @@ -650,6 +665,7 @@ class UniversalTrader: # Get pool address for price monitoring using platform-agnostic method pool_address = self._get_pool_address(token_info) curve_manager = self.platform_implementations.curve_manager + exit_sell_attempts = 0 while position.is_active: try: @@ -669,11 +685,16 @@ class UniversalTrader: f"Position PnL: {pnl['price_change_pct']:.2f}% ({pnl['unrealized_pnl_sol']:.6f} SOL)" ) - # Execute sell with position quantity and entry price to avoid RPC delays + # Sell against the price that just triggered the exit, not + # the entry price: the seller turns this into the slippage + # floor, and by definition an exit fires once the price has + # moved away from entry. current_price cost no extra RPC + # call — it was fetched at the top of this iteration. + exit_sell_attempts += 1 sell_result = await self.seller.execute( token_info, token_amount=position.quantity, - token_price=position.entry_price, + token_price=current_price, ) if sell_result.success: @@ -708,15 +729,25 @@ class UniversalTrader: 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 - break + logger.error( + f"Failed to exit position (attempt " + f"{exit_sell_attempts}/{self.max_exit_sell_attempts}): " + f"{sell_result.error_message}" + ) + if exit_sell_attempts >= self.max_exit_sell_attempts: + logger.error( + f"Giving up on exiting {token_info.symbol} after " + f"{exit_sell_attempts} attempts. Position stays open " + f"and is no longer monitored - tokens are still held." + ) + break + # Keep monitoring: the next iteration re-reads the price and + # retries the sell with a floor that matches the market. else: # Log current status + exit_sell_attempts = 0 pnl = position.get_pnl(current_price) logger.debug( f"Position status: {current_price:.8f} SOL ({pnl['price_change_pct']:+.2f}%)"