mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
455 lines
16 KiB
Python
455 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
|
|
) -> 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())
|