mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-25 13:18:04 +00:00
fix(trading): price tp/sl exits off the trigger price and retry a reverted sell (#193)
* fix(trading): price tp/sl exits off the trigger price and retry a reverted sell `_monitor_position_until_exit` handed `position.entry_price` to the sell while the `current_price` that had just triggered the exit sat in the same scope, one line up. `PlatformAwareSeller.execute` does not read a price - the `token_price` it receives *is* the slippage floor - so a stop-loss priced off the entry demands more quote asset than the curve can pay and reverts with 6003 `TooLittleSolReceived`, during the very drop the stop-loss exists to escape. On a take-profit the same mistake runs the other way and the floor protects nothing. `current_price` costs no extra RPC call; `_handle_time_based_exit` has nothing fresher and keeps passing the buy price. The `break` also 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 directly above it. The seller's `max_retries` covers transaction submission only, so an on-chain revert was never retried and the position was abandoned mid-crash with `is_active=True`. A failed exit sell now retries on the next price check, re-reading the price so the floor tracks the market, bounded by `trade.max_exit_sell_attempts` (default 3, validated to 1..100) so a permanently reverting token cannot pin the bot on one position. The counter resets if the price recovers out of the exit band, and giving up is logged loudly since the tokens are still held. Fixes #189 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(learning-examples): machine-check the tp/sl exit price and retry path Drives the real `_monitor_position_until_exit` with a stub curve manager serving a scripted price series and a stub seller that records the price it is handed. Offline, no network and no funds moved, per the `verify_*` convention. Eight checks: both exit kinds sell at the triggering price, the entry-price floor is arithmetically unpayable on a drop while the trigger-price floor is payable, a reverted sell is retried and a landing retry closes the position, retries stay bounded, a price recovery resets the counter, a successful sell still closes on the first attempt, and the cap comes from `trade.max_exit_sell_attempts` wired through bot_runner and config_loader. Mutation-tested rather than trusted on a green run - reintroducing the stale entry price drops it to 4/8, giving up after one failure to 4/8, and ignoring the config knob to 7/8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: document the tp/sl exit path and its verifier The caller owns the sell's slippage floor, which is not obvious from `PlatformAwareSeller.execute` - it never reads a price, it just turns `token_price` into `min_quote_output`. Records that, why an exit must price off the triggering price, and that the seller's `max_retries` covers submission only so an on-chain revert has to be retried in the monitor loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b367191267
commit
a0540fdc9e
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
+7
-1
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
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}%)"
|
||||
|
||||
Reference in New Issue
Block a user