mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* fix(trading): sell the exact raw token count the buy received The buy read the raw token count off its transaction, kept it as a float, and the sell rebuilt it with int(amount * 10**6), which for some counts comes out one raw unit short and leaves dust in the token account. The raw count now travels on TradeResult and Position to the seller, and an exact-in buy converts its configured spend through its decimal text. * fix(trading): keep the exact token count when the spend cannot be read * fix(trading): price an exact-in buy from its spend when the spend can't be read
459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""Verify max_hold_time still fires when the price read keeps failing.
|
|
|
|
`UniversalTrader._monitor_position_until_exit` read the price at the top of each
|
|
iteration and evaluated every exit condition after it. `position.should_exit()`
|
|
takes `current_price`, so a failed read skipped the whole check - including
|
|
`max_hold_time`, which needs no price at all.
|
|
|
|
If the read kept failing the loop spun forever: `position.is_active` never
|
|
changed, the position was never sold, and the only signal was a repeating
|
|
`Error monitoring position`. A load-balanced endpoint serving nodes behind the
|
|
one that confirmed the buy is enough to trigger it; a rate-limit storm would
|
|
hold the position open indefinitely.
|
|
|
|
Offline machine checks, no network and no funds moved. The real monitor loop
|
|
runs against a curve manager that fails on demand:
|
|
|
|
1. max_hold_time fires with the price feed down - the loop is not infinite.
|
|
2. The blind exit is floored against the last price actually read.
|
|
3. With no successful read ever, it falls back to the entry price.
|
|
4. Before the deadline, a failing read keeps monitoring and sells nothing.
|
|
5. The blind exit is still bounded by trade.max_exit_sell_attempts.
|
|
6. should_exit_on_time stays False without a deadline or an open position.
|
|
7. A curve that prices at 0.0 never becomes the slippage floor, on either
|
|
the blind path or the ordinary one.
|
|
|
|
Usage:
|
|
uv run tests/regression/verify_time_exit_without_price.py
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
|
|
|
from solders.pubkey import Pubkey # noqa: E402
|
|
|
|
from interfaces.core import Platform, TokenInfo, TradeFailureReason # noqa: E402
|
|
from trading.base import TradeResult # noqa: E402
|
|
from trading.position import ExitReason, Position # noqa: E402
|
|
from trading.universal_trader import UniversalTrader # noqa: E402
|
|
|
|
BUY_PRICE = 1.0e-6
|
|
QUANTITY = 1_000_000.0
|
|
READABLE_PRICE = BUY_PRICE * 0.8 # read once, before the feed goes down
|
|
MAX_HOLD_TIME = 30 # seconds
|
|
MAX_EXIT_SELL_ATTEMPTS = 3
|
|
|
|
# A loop that cannot terminate is the bug under test, so every run is bounded.
|
|
EXIT_TIMEOUT = 5
|
|
STILL_MONITORING_TIMEOUT = 0.5
|
|
|
|
# For the "feed dies mid-hold" case: short enough to cross during a run, long
|
|
# enough that the first read lands before it.
|
|
SHORT_HOLD_TIME = 1
|
|
CALM_CHECK_INTERVAL = 0.05
|
|
|
|
RPC_DOWN = "Invalid bonding curve state: Account not found"
|
|
NON_POSITIVE_PRICE = "token_price is required for sell operation and must be positive."
|
|
|
|
# What calculate_price returns for a curve with no virtual token reserves left.
|
|
# It does not raise, so a naive `last_known_price = current_price` stores it,
|
|
# and the seller rejects a non-positive price with a ValueError raised before
|
|
# its own try block - escaping the bounded exit handling entirely.
|
|
CURVE_PRICED_AT_ZERO = 0.0
|
|
|
|
|
|
class FlakyCurveManager:
|
|
"""Serves a few prices, then fails every read from then on."""
|
|
|
|
def __init__(self, prices: list[float] | None = None) -> None:
|
|
self.prices = list(prices or [])
|
|
self.calls = 0
|
|
|
|
async def calculate_price(self, _pool_address: Pubkey) -> float:
|
|
"""Serve the next price, or fail once the script runs dry.
|
|
|
|
Returns:
|
|
The next scripted price
|
|
|
|
Raises:
|
|
ValueError: Once no scripted prices remain
|
|
"""
|
|
self.calls += 1
|
|
if self.prices:
|
|
return self.prices.pop(0)
|
|
raise ValueError(RPC_DOWN)
|
|
|
|
|
|
class RecordingSeller:
|
|
"""Records the floor each sell was priced against."""
|
|
|
|
def __init__(self, fail_first: int = 0) -> None:
|
|
self.fail_first = fail_first
|
|
self.prices_seen: list[float] = []
|
|
|
|
async def execute(
|
|
self,
|
|
token_info: TokenInfo,
|
|
token_amount: float,
|
|
token_price: float,
|
|
token_amount_raw: int | None = None,
|
|
) -> TradeResult:
|
|
"""Record the attempt and return the scripted outcome.
|
|
|
|
Args:
|
|
token_info: Token being sold
|
|
token_amount: Amount asked for
|
|
token_price: Price the sell is floored against
|
|
|
|
Returns:
|
|
The scripted TradeResult
|
|
|
|
Raises:
|
|
ValueError: On a non-positive price, exactly as the real seller
|
|
does - and, like the real one, before any try block.
|
|
"""
|
|
self.prices_seen.append(token_price)
|
|
if token_price is None or token_price <= 0:
|
|
raise ValueError(NON_POSITIVE_PRICE)
|
|
if len(self.prices_seen) <= self.fail_first:
|
|
return TradeResult(
|
|
success=False,
|
|
platform=token_info.platform,
|
|
error_message="scripted revert",
|
|
failure_reason=TradeFailureReason.REVERTED,
|
|
)
|
|
return TradeResult(
|
|
success=True,
|
|
platform=token_info.platform,
|
|
tx_signature="stub-sell",
|
|
amount=token_amount,
|
|
price=token_price,
|
|
)
|
|
|
|
|
|
def _make_token_info() -> TokenInfo:
|
|
"""Build the minimum TokenInfo the monitor loop touches.
|
|
|
|
Returns:
|
|
A pump.fun TokenInfo
|
|
"""
|
|
mint = Pubkey.from_string("11111111111111111111111111111112")
|
|
return TokenInfo(
|
|
name="Stub",
|
|
symbol="STUB",
|
|
uri="",
|
|
mint=mint,
|
|
platform=Platform.PUMP_FUN,
|
|
bonding_curve=mint,
|
|
user=mint,
|
|
creator=mint,
|
|
)
|
|
|
|
|
|
def _make_trader(
|
|
curve_manager: FlakyCurveManager,
|
|
seller: RecordingSeller,
|
|
price_check_interval: float = 0,
|
|
) -> UniversalTrader:
|
|
"""Build a trader carrying only what the monitor loop touches.
|
|
|
|
Args:
|
|
curve_manager: Stub price source
|
|
seller: Stub seller
|
|
price_check_interval: Delay between iterations
|
|
|
|
Returns:
|
|
A UniversalTrader wired to the stubs
|
|
"""
|
|
trader = object.__new__(UniversalTrader)
|
|
trader.price_check_interval = price_check_interval
|
|
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
|
|
trader._log_trade = lambda *_a, **_kw: None # noqa: SLF001
|
|
return trader
|
|
|
|
|
|
def _make_position(
|
|
*, past_deadline: bool, max_hold_time: int = MAX_HOLD_TIME
|
|
) -> Position:
|
|
"""Build an open position with max_hold_time already met or not.
|
|
|
|
Args:
|
|
past_deadline: Whether the hold deadline has already passed
|
|
max_hold_time: Hold deadline in seconds
|
|
|
|
Returns:
|
|
An active Position with no tp/sl set
|
|
"""
|
|
entry_time = datetime.utcnow()
|
|
if past_deadline:
|
|
entry_time -= timedelta(seconds=max_hold_time + 1)
|
|
return Position(
|
|
mint=Pubkey.from_string("11111111111111111111111111111112"),
|
|
symbol="STUB",
|
|
entry_price=BUY_PRICE,
|
|
quantity=QUANTITY,
|
|
entry_time=entry_time,
|
|
max_hold_time=max_hold_time,
|
|
)
|
|
|
|
|
|
async def _monitor(
|
|
prices: list[float] | None,
|
|
position: Position,
|
|
*,
|
|
fail_first: int = 0,
|
|
timeout: float = EXIT_TIMEOUT,
|
|
price_check_interval: float = 0,
|
|
) -> tuple[RecordingSeller, Position, FlakyCurveManager, bool]:
|
|
"""Drive the real monitor loop with a price feed that goes down.
|
|
|
|
Args:
|
|
prices: Prices served before the feed fails
|
|
position: The open position to monitor
|
|
fail_first: How many sells revert before one succeeds
|
|
timeout: Wall-clock bound, since the bug under test is an endless loop
|
|
price_check_interval: Delay between iterations
|
|
|
|
Returns:
|
|
(seller, position, curve_manager, timed_out)
|
|
"""
|
|
curve_manager = FlakyCurveManager(prices)
|
|
seller = RecordingSeller(fail_first=fail_first)
|
|
trader = _make_trader(curve_manager, seller, price_check_interval)
|
|
timed_out = False
|
|
try:
|
|
async with asyncio.timeout(timeout):
|
|
await trader._monitor_position_until_exit( # noqa: SLF001
|
|
_make_token_info(), position
|
|
)
|
|
except TimeoutError:
|
|
timed_out = True
|
|
return seller, position, curve_manager, timed_out
|
|
|
|
|
|
def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001
|
|
"""Print one check result.
|
|
|
|
Args:
|
|
label: What was checked
|
|
passed: Whether it held
|
|
detail: Evidence behind the verdict
|
|
|
|
Returns:
|
|
The value of `passed`
|
|
"""
|
|
print(f"{'PASS' if passed else 'FAIL'} {label}\n {detail}")
|
|
return passed
|
|
|
|
|
|
async def check_time_exit_fires_without_price() -> bool:
|
|
"""The deadline is enforced even with every price read failing.
|
|
|
|
Returns:
|
|
Whether the check passed
|
|
"""
|
|
seller, position, _curve, timed_out = await _monitor(
|
|
[], _make_position(past_deadline=True)
|
|
)
|
|
return _check(
|
|
"max_hold_time fires with the price feed down - the loop terminates",
|
|
not timed_out and len(seller.prices_seen) >= 1 and not position.is_active,
|
|
f"timed out: {timed_out}, {len(seller.prices_seen)} sell(s), "
|
|
f"position active: {position.is_active}, "
|
|
f"exit reason: {position.exit_reason.value if position.exit_reason else None}",
|
|
)
|
|
|
|
|
|
async def check_uses_last_known_price() -> bool:
|
|
"""The blind exit is floored against the last price actually read.
|
|
|
|
The feed has to die *after* a successful read and *before* the deadline,
|
|
or the exit goes out through the ordinary priced path and proves nothing.
|
|
|
|
Returns:
|
|
Whether the check passed
|
|
"""
|
|
seller, _position, curve, timed_out = await _monitor(
|
|
[READABLE_PRICE],
|
|
_make_position(past_deadline=False, max_hold_time=SHORT_HOLD_TIME),
|
|
price_check_interval=CALM_CHECK_INTERVAL,
|
|
)
|
|
floored_at = seller.prices_seen[0] if seller.prices_seen else None
|
|
went_blind = curve.calls > 1 # the exit came after reads started failing
|
|
return _check(
|
|
"the blind exit prices off the last price actually read",
|
|
not timed_out and went_blind and floored_at == READABLE_PRICE,
|
|
f"sold against {floored_at} after {curve.calls} read(s), "
|
|
f"last good read {READABLE_PRICE}, entry {BUY_PRICE}",
|
|
)
|
|
|
|
|
|
async def check_falls_back_to_entry_price() -> bool:
|
|
"""With no read ever succeeding, the entry price is the floor.
|
|
|
|
Returns:
|
|
Whether the check passed
|
|
"""
|
|
seller, _position, _curve, timed_out = await _monitor(
|
|
[], _make_position(past_deadline=True)
|
|
)
|
|
floored_at = seller.prices_seen[0] if seller.prices_seen else None
|
|
return _check(
|
|
"with no successful read ever, it falls back to the entry price",
|
|
not timed_out and floored_at == BUY_PRICE,
|
|
f"sold against {floored_at}, entry {BUY_PRICE}",
|
|
)
|
|
|
|
|
|
async def check_no_early_exit() -> bool:
|
|
"""Before the deadline, a failing read sells nothing.
|
|
|
|
Returns:
|
|
Whether the check passed
|
|
"""
|
|
seller, position, _curve, timed_out = await _monitor(
|
|
[],
|
|
_make_position(past_deadline=False),
|
|
timeout=STILL_MONITORING_TIMEOUT,
|
|
)
|
|
return _check(
|
|
"before the deadline, a failing read keeps monitoring and sells nothing",
|
|
timed_out and not seller.prices_seen and position.is_active,
|
|
f"{len(seller.prices_seen)} sell(s), still monitoring: {timed_out}, "
|
|
f"position active: {position.is_active}",
|
|
)
|
|
|
|
|
|
async def check_blind_exit_is_bounded() -> bool:
|
|
"""A blind exit that keeps reverting gives up rather than looping.
|
|
|
|
Returns:
|
|
Whether the check passed
|
|
"""
|
|
seller, position, _curve, timed_out = await _monitor(
|
|
[],
|
|
_make_position(past_deadline=True),
|
|
fail_first=MAX_EXIT_SELL_ATTEMPTS + 5,
|
|
)
|
|
return _check(
|
|
"a blind exit that keeps reverting is bounded by max_exit_sell_attempts",
|
|
not timed_out and len(seller.prices_seen) == MAX_EXIT_SELL_ATTEMPTS,
|
|
f"{len(seller.prices_seen)} attempt(s), cap {MAX_EXIT_SELL_ATTEMPTS}, "
|
|
f"position left open: {position.is_active}",
|
|
)
|
|
|
|
|
|
async def check_zero_price_never_becomes_the_floor() -> bool:
|
|
"""A curve priced at 0.0 is never handed to the seller as a floor.
|
|
|
|
Two ways it could be. The blind path stores the last read, so a 0.0 would
|
|
be replayed once the feed went down. The ordinary path is worse: 0.0
|
|
satisfies `current_price <= stop_loss_price`, so the stop loss fires and
|
|
the exit is priced off the same 0.0 in the same iteration.
|
|
|
|
Returns:
|
|
Whether the check passed
|
|
"""
|
|
# Ordinary path: one readable 0.0, which trips the stop loss immediately.
|
|
curve = FlakyCurveManager([CURVE_PRICED_AT_ZERO])
|
|
seller = RecordingSeller()
|
|
trader = _make_trader(curve, seller)
|
|
position = _make_position(past_deadline=False)
|
|
position.stop_loss_price = BUY_PRICE * 0.5 # 0.0 is comfortably below this
|
|
timed_out = False
|
|
try:
|
|
async with asyncio.timeout(EXIT_TIMEOUT):
|
|
await trader._monitor_position_until_exit( # noqa: SLF001
|
|
_make_token_info(), position
|
|
)
|
|
except TimeoutError:
|
|
timed_out = True
|
|
except ValueError:
|
|
escaped = False # the ValueError got out; that is the bug
|
|
return _check(
|
|
"a curve priced at 0.0 never becomes the slippage floor",
|
|
escaped,
|
|
"the seller's non-positive price ValueError escaped the monitor loop",
|
|
)
|
|
|
|
floored_at = seller.prices_seen[0] if seller.prices_seen else None
|
|
return _check(
|
|
"a curve priced at 0.0 never becomes the slippage floor",
|
|
not timed_out
|
|
and floored_at == BUY_PRICE
|
|
and all(p > 0 for p in seller.prices_seen),
|
|
f"stop loss fired on a 0.0 read; sold against {floored_at} "
|
|
f"(entry {BUY_PRICE}), all floors positive: "
|
|
f"{all(p > 0 for p in seller.prices_seen)}",
|
|
)
|
|
|
|
|
|
def check_should_exit_on_time_is_narrow() -> bool:
|
|
"""should_exit_on_time answers only its own question.
|
|
|
|
Returns:
|
|
Whether the check passed
|
|
"""
|
|
no_deadline = _make_position(past_deadline=True)
|
|
no_deadline.max_hold_time = None
|
|
closed = _make_position(past_deadline=True)
|
|
closed.close_position(BUY_PRICE, ExitReason.MANUAL)
|
|
due = _make_position(past_deadline=True)
|
|
early = _make_position(past_deadline=False)
|
|
|
|
return _check(
|
|
"should_exit_on_time is False without a deadline or an open position",
|
|
not no_deadline.should_exit_on_time()
|
|
and not closed.should_exit_on_time()
|
|
and due.should_exit_on_time()
|
|
and not early.should_exit_on_time(),
|
|
f"no max_hold_time: {no_deadline.should_exit_on_time()}, "
|
|
f"already closed: {closed.should_exit_on_time()}, "
|
|
f"past deadline: {due.should_exit_on_time()}, "
|
|
f"before deadline: {early.should_exit_on_time()}",
|
|
)
|
|
|
|
|
|
async def main() -> None:
|
|
"""Run every check and exit non-zero if any failed."""
|
|
print("=" * 72)
|
|
print("Verifying max_hold_time survives a dead price feed")
|
|
print("=" * 72)
|
|
|
|
results = [
|
|
await check_time_exit_fires_without_price(),
|
|
await check_uses_last_known_price(),
|
|
await check_falls_back_to_entry_price(),
|
|
await check_no_early_exit(),
|
|
await check_blind_exit_is_bounded(),
|
|
check_should_exit_on_time_is_narrow(),
|
|
await check_zero_price_never_becomes_the_floor(),
|
|
]
|
|
|
|
print("\n" + "=" * 72)
|
|
if all(results):
|
|
print(f"ALL {len(results)} CHECKS PASSED")
|
|
else:
|
|
print(f"{results.count(False)}/{len(results)} CHECKS FAILED")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|