diff --git a/docs/regression-tests.md b/docs/regression-tests.md index f6155c4..930602f 100644 --- a/docs/regression-tests.md +++ b/docs/regression-tests.md @@ -29,6 +29,7 @@ individual scripts for a subset. | `verify_cleanup_survives_shutdown.py` | shutdown cleanup runs to completion through the cancellation that triggered it, bounded, and a cleanup that hangs or raises still lets the process exit | | `verify_token_queue_shutdown.py` | cancelling the token queue processor ends it without raising, and `task_done()` stays balanced on the paths that took an item | | `verify_pumpfun_buy_token_amount.py` | a pump.fun `buy_v2` asks for the whole sized token count with slippage on the cost ceiling, in extreme_fast_mode and on the regular path | +| `verify_exact_token_amounts.py` | a position sells the raw token count its buy read, not a float rebuilt from it, including when the buy falls back to the wallet balance; an exact-in buy spends the configured amount to the raw unit | | `verify_buy_result_not_lost.py` | a landed buy is never reported failed, and a reverted one never reported landed | | `verify_tx_status_checks.py` | every path reads `meta.err`; `--live` replays known reverted signatures | | `verify_tp_sl_exit_price.py` | the tp/sl exit prices off the trigger price, and a reverted sell is retried, bounded | diff --git a/src/trading/base.py b/src/trading/base.py index c3d48e8..9fc3b24 100644 --- a/src/trading/base.py +++ b/src/trading/base.py @@ -19,6 +19,8 @@ class TradeResult: tx_signature: str | None = None error_message: str | None = None amount: float | None = None + #: `amount` in raw token units, exact. None when only an estimate exists. + amount_raw: int | None = None price: float | None = None # Why a failed trade failed, when the caller has to decide whether # resubmitting is safe. None on success, and on failures that predate the @@ -37,6 +39,7 @@ class TradeResult: "tx_signature": self.tx_signature, "error_message": self.error_message, "amount": self.amount, + "amount_raw": self.amount_raw, "price": self.price, "failure_reason": ( self.failure_reason.value if self.failure_reason else None diff --git a/src/trading/platform_aware.py b/src/trading/platform_aware.py index 58ce6ca..1f7b2b7 100644 --- a/src/trading/platform_aware.py +++ b/src/trading/platform_aware.py @@ -1,6 +1,7 @@ """Platform-aware trader implementations built on the interface system.""" import asyncio +from decimal import Decimal from time import monotonic from solders.pubkey import Pubkey @@ -289,8 +290,11 @@ class PlatformAwareBuyer(Trader): # exact-in program spends all of amount_in, so it gets the # configured amount and a token floor. Otherwise the token count # is exact (pump.fun buy_v2) and amount_in is the ceiling. - if getattr(instruction_builder, "spends_exact_amount_in", False): - max_quote_amount_raw = int(quote_amount * quote_unit) + exact_in = getattr(instruction_builder, "spends_exact_amount_in", False) + if exact_in: + # The configured spend is a decimal like 0.003; scaling the float + # can land one raw unit short of it. + max_quote_amount_raw = int(Decimal(str(quote_amount)) * quote_unit) token_amount_raw = int( token_amount * (1 - self.slippage) * 10**TOKEN_DECIMALS ) @@ -352,22 +356,34 @@ class PlatformAwareBuyer(Trader): quote_mint=quote_mint, ) - if tokens_raw is not None and quote_spent is not None: + # The exact raw count, carried to the sell: converting the float + # back can come out a raw unit short and leave dust behind. + amount_raw = None + if tokens_raw is not None: + # The token count stands on its own; a missing spend only + # leaves the price at its estimate. + amount_raw = tokens_raw actual_amount = tokens_raw / 10**TOKEN_DECIMALS - actual_price = (quote_spent / quote_unit) / actual_amount logger.info( f"Actual tokens received: {actual_amount:.6f} " f"(expected: {token_amount:.6f})" ) - logger.info( - f"Actual {quote_label} spent: " - f"{quote_spent / quote_unit:.10f} {quote_label}" - ) - logger.info( - f"Actual price: {actual_price:.10f} {quote_label}/token" - ) token_amount = actual_amount - token_price_sol = actual_price + if quote_spent is None and exact_in and tokens_raw > 0: + # An exact-in buy spent what it submitted; pairing the + # actual count with the estimated price would set an + # unreachable sell floor. + quote_spent = max_quote_amount_raw + if quote_spent is not None and tokens_raw > 0: + actual_price = (quote_spent / quote_unit) / actual_amount + logger.info( + f"Actual {quote_label} spent: " + f"{quote_spent / quote_unit:.10f} {quote_label}" + ) + logger.info( + f"Actual price: {actual_price:.10f} {quote_label}/token" + ) + token_price_sol = actual_price else: # confirm_transaction already read meta.err, so the buy # executed — only the amounts could not be read back. @@ -379,8 +395,9 @@ class PlatformAwareBuyer(Trader): f"quote_spent={quote_spent}) from tx {tx_signature}; " f"the buy did land, falling back to the wallet balance" ) - balance = await self._read_token_balance(token_info) - if balance is not None and balance > 0: + balance_raw = await self._read_token_balance(token_info) + if balance_raw is not None and balance_raw > 0: + balance = balance_raw / 10**TOKEN_DECIMALS # The balance is cumulative, so cap it at what this buy # asked for: the rest was already held, and selling it # would liquidate an unrelated position. An exact delta @@ -390,7 +407,10 @@ class PlatformAwareBuyer(Trader): f"Token balance after buy: {balance:.6f}, selling at " f"most the {token_amount:.6f} this buy asked for" ) - token_amount = min(balance, token_amount) + amount_raw = min( + balance_raw, int(token_amount * 10**TOKEN_DECIMALS) + ) + token_amount = amount_raw / 10**TOKEN_DECIMALS else: logger.warning( f"Token balance unreadable too; holding the expected " @@ -403,6 +423,7 @@ class PlatformAwareBuyer(Trader): platform=token_info.platform, tx_signature=tx_signature, amount=token_amount, + amount_raw=amount_raw, price=token_price_sol, ) else: @@ -430,7 +451,7 @@ class PlatformAwareBuyer(Trader): return address_provider.derive_pool_address(token_info.mint) - async def _read_token_balance(self, token_info: TokenInfo) -> float | None: + async def _read_token_balance(self, token_info: TokenInfo) -> int | None: """Read how many of a coin the wallet actually holds. Used only when a landed buy's amounts could not be parsed back out of the @@ -441,7 +462,7 @@ class PlatformAwareBuyer(Trader): token_info: Token information carrying the mint and token program Returns: - Balance in whole tokens, or None if it cannot be read + Balance in raw token units, or None if it cannot be read """ token_program = ( token_info.token_program_id or SystemAddresses.TOKEN_2022_PROGRAM @@ -454,7 +475,7 @@ class PlatformAwareBuyer(Trader): except Exception as e: # noqa: BLE001 logger.warning(f"Could not read token balance for {token_info.mint}: {e}") return None - return balance_raw / 10**TOKEN_DECIMALS + return balance_raw def _can_skip_refresh(self, token_info: TokenInfo) -> bool: """Whether the pre-buy curve read can be skipped entirely. @@ -647,7 +668,11 @@ class PlatformAwareSeller(Trader): self.compute_units = compute_units or {} async def execute( - self, token_info: TokenInfo, token_amount: float, token_price: float + self, + token_info: TokenInfo, + token_amount: float, + token_price: float, + token_amount_raw: int | None = None, ) -> TradeResult: """Execute sell operation using platform-specific implementations. @@ -660,6 +685,9 @@ class PlatformAwareSeller(Trader): the freshest price available: a stale price above the market sets a floor the pool cannot pay and the sell reverts (pump.fun 6003 TooLittleSolReceived). + token_amount_raw: `token_amount` in raw units, exact. Pass it when the + buy read it: rebuilt from the float, the amount can come out a raw + unit short and leave dust in the token account. Returns: TradeResult with operation outcome @@ -779,7 +807,11 @@ class PlatformAwareSeller(Trader): # Use pre-known amount and price (no RPC delay) token_balance_decimal = token_amount - token_balance = int(token_amount * 10**TOKEN_DECIMALS) + token_balance = ( + token_amount_raw + if token_amount_raw is not None + else int(token_amount * 10**TOKEN_DECIMALS) + ) token_price_sol = token_price logger.info(f"Token balance: {token_balance_decimal:.6f}") diff --git a/src/trading/position.py b/src/trading/position.py index 5ebcfa7..d5ba433 100644 --- a/src/trading/position.py +++ b/src/trading/position.py @@ -28,6 +28,8 @@ class Position: entry_price: float quantity: float entry_time: datetime + #: `quantity` in raw token units, exact; the sell uses it when known. + quantity_raw: int | None = None # Exit conditions take_profit_price: float | None = None @@ -50,6 +52,7 @@ class Position: take_profit_percentage: float | None = None, stop_loss_percentage: float | None = None, max_hold_time: int | None = None, + quantity_raw: int | None = None, ) -> "Position": """Create a position from a successful buy transaction. @@ -61,6 +64,7 @@ class Position: take_profit_percentage: Take profit percentage (0.5 = 50% profit) stop_loss_percentage: Stop loss percentage (0.2 = 20% loss) max_hold_time: Maximum hold time in seconds + quantity_raw: `quantity` in raw token units, when the buy read it exactly Returns: Position instance @@ -78,6 +82,7 @@ class Position: symbol=symbol, entry_price=entry_price, quantity=quantity, + quantity_raw=quantity_raw, entry_time=datetime.utcnow(), take_profit_price=take_profit_price, stop_loss_price=stop_loss_price, diff --git a/src/trading/universal_trader.py b/src/trading/universal_trader.py index 6b5e4fd..e065788 100644 --- a/src/trading/universal_trader.py +++ b/src/trading/universal_trader.py @@ -742,6 +742,7 @@ class UniversalTrader: symbol=token_info.symbol, entry_price=buy_result.price, quantity=buy_result.amount, + quantity_raw=buy_result.amount_raw, take_profit_percentage=self.take_profit_percentage, stop_loss_percentage=self.stop_loss_percentage, max_hold_time=self.max_hold_time, @@ -779,7 +780,10 @@ class UniversalTrader: for attempt in range(1, self.max_exit_sell_attempts + 1): sell_result: TradeResult = await self.seller.execute( - token_info, token_amount=buy_result.amount, token_price=token_price + token_info, + token_amount=buy_result.amount, + token_price=token_price, + token_amount_raw=buy_result.amount_raw, ) if sell_result.success: @@ -1118,6 +1122,7 @@ class UniversalTrader: token_info, token_amount=position.quantity, token_price=price, + token_amount_raw=position.quantity_raw, ) if sell_result.success: diff --git a/tests/regression/verify_exact_token_amounts.py b/tests/regression/verify_exact_token_amounts.py new file mode 100644 index 0000000..78c41d2 --- /dev/null +++ b/tests/regression/verify_exact_token_amounts.py @@ -0,0 +1,312 @@ +"""Verify a position sells exactly what the buy received, and an exact-in buy spends what was configured. + +The buy read the exact raw token count off its transaction, stored it as a float +in whole tokens, and the sell rebuilt the raw count with `int(amount * 10**6)`. +For some counts, at every position size, that comes out one raw unit short, so +the sell left dust behind and the token account could not be closed without a +burn. The same float scaling could also put an exact-in buy's spend one raw +unit below the configured amount. + +Offline machine checks, no network and no funds moved. Buyer and seller run +through the real pump.fun instruction builder; instruction data is decoded: + + A. The chosen raw count really is one the float round trip loses. + B. A landed buy reports the transaction's raw token count as `amount_raw`, + also when the spend could not be read back. + C. When the amounts cannot be parsed back, `amount_raw` is the wallet's raw + balance, capped at what the buy asked for. + D. The sell's `sell_v2` amount is `amount_raw` exactly when it is passed. + E. Both exit paths in the trader hand the raw amount to the seller, and the + tp/sl position keeps it. + F. An exact-in buy spends the configured amount to the raw unit, and when + the spend cannot be read back, prices the position from that amount. + +Usage: + uv run tests/regression/verify_exact_token_amounts.py +""" + +import asyncio +import inspect +import struct +import sys +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 core.pubkeys import WSOL_MINT, SystemAddresses # noqa: E402 +from interfaces.core import ConfirmationStatus, Platform, TokenInfo # noqa: E402 +from platforms.pumpfun.address_provider import ( # noqa: E402 + PumpFunAddresses, + PumpFunAddressProvider, +) +from platforms.pumpfun.instruction_builder import PumpFunInstructionBuilder # noqa: E402 +from trading import platform_aware, universal_trader # noqa: E402 +from trading.platform_aware import PlatformAwareBuyer, PlatformAwareSeller # noqa: E402 +from utils.idl_manager import get_idl_manager # noqa: E402 + +TRADER = Pubkey.from_string("11111111111111111111111111111112") +MINT = Pubkey.new_unique() +PROVIDER = PumpFunAddressProvider() +BUILDER = PumpFunInstructionBuilder(get_idl_manager().get_parser(Platform.PUMP_FUN)) + +PRICE = 0.00000004 +# A raw count the float round trip loses; check A proves it. +LOSSY_RAW = 65_758_096_180 +EXPECTED_TOKENS = 50_000 + + +def _token_info() -> TokenInfo: + bonding_curve = PROVIDER.derive_pool_address(MINT) + return TokenInfo( + name="T", + symbol="T", + uri="", + mint=MINT, + platform=Platform.PUMP_FUN, + bonding_curve=bonding_curve, + associated_bonding_curve=PROVIDER.derive_associated_bonding_curve( + MINT, bonding_curve, SystemAddresses.TOKEN_2022_PROGRAM + ), + user=TRADER, + creator=TRADER, + creator_vault=PROVIDER.derive_creator_vault(TRADER), + token_program_id=SystemAddresses.TOKEN_2022_PROGRAM, + quote_mint=WSOL_MINT, + state_from_event=True, + ) + + +class _Client: + def __init__(self, *, tokens_raw=None, balance_raw=None, spent=1_000_000) -> None: + self.sent: list = [] + self.tokens_raw = tokens_raw + self.balance_raw = balance_raw + self.spent = spent + + async def build_and_send_transaction(self, instructions, *_a, **_k) -> str: + self.sent.append(instructions) + return "STUB_SIGNATURE" + + async def confirm_transaction(self, *_a, **_k) -> bool: + return True + + async def confirm_transaction_detailed(self, *_a, **_k): + return ConfirmationStatus.SUCCESS + + async def get_buy_transaction_details(self, *_a, **_k): + if self.tokens_raw is None: + return None, None + return self.tokens_raw, self.spent + + async def get_token_account_balance(self, _ata) -> int: + return self.balance_raw + + async def get_sell_transaction_details(self, *_a, **_k): + return None, None + + +class _Curve: + async def get_pool_state(self, _pool, commitment=None) -> dict: # noqa: ARG002 + return { + "price_per_token": PRICE, + "quote_mint": WSOL_MINT, + "creator": str(TRADER), + "is_mayhem_mode": False, + "is_cashback_coin": False, + } + + +async def _no_fee(_accounts: list) -> None: + return None + + +def _wire(builder=BUILDER) -> None: + platform_aware.get_platform_implementations = lambda _p, _c: SimpleNamespace( + address_provider=PROVIDER, + instruction_builder=builder, + curve_manager=_Curve(), + graduated_market=None, + ) + + +def _wallet() -> SimpleNamespace: + return SimpleNamespace( + pubkey=TRADER, + keypair=None, + get_associated_token_address=lambda mint, program: Pubkey.find_program_address( + [bytes(TRADER), bytes(program), bytes(mint)], + SystemAddresses.ASSOCIATED_TOKEN_PROGRAM, + )[0], + ) + + +def _buy(client: _Client, *, amount: float = 0.002, builder=BUILDER): + _wire(builder) + buyer = PlatformAwareBuyer( + client, + _wallet(), + SimpleNamespace(calculate_priority_fee=_no_fee), + amount=amount, + slippage=0.5, + max_retries=1, + extreme_fast_token_amount=EXPECTED_TOKENS, + extreme_fast_mode=True, + ) + return asyncio.run(buyer.execute(_token_info())) + + +def check_a_round_trip_is_lossy() -> bool: + """A: the float round trip really drops a unit for this raw count.""" + rebuilt = int((LOSSY_RAW / 10**6) * 10**6) + ok = rebuilt == LOSSY_RAW - 1 + if not ok: + print(f" rebuilt {rebuilt}, expected {LOSSY_RAW - 1}") + return ok + + +def check_b_buy_reports_exact_raw() -> bool: + """B: amount_raw is the transaction's raw token count, spend read or not.""" + full = _buy(_Client(tokens_raw=LOSSY_RAW)) + # No spend and no balance: only the transaction's token count is left. + no_spend = _buy(_Client(tokens_raw=LOSSY_RAW, spent=None, balance_raw=None)) + ok = ( + full.success + and full.amount_raw == LOSSY_RAW + and no_spend.amount_raw == LOSSY_RAW + ) + if not ok: + print(f" full={full.amount_raw} no_spend={no_spend.amount_raw}") + return ok + + +def check_c_balance_fallback_is_raw() -> bool: + """C: unparsed buy falls back to the raw balance, capped at the request.""" + held = 12_345_678_901 # below the 50,000 tokens asked for + below = _buy(_Client(tokens_raw=None, balance_raw=held)) + above = _buy(_Client(tokens_raw=None, balance_raw=EXPECTED_TOKENS * 10**6 + 999)) + ok = below.amount_raw == held and above.amount_raw == EXPECTED_TOKENS * 10**6 + if not ok: + print(f" below={below.amount_raw} above={above.amount_raw}") + return ok + + +def _sell_amount(**kwargs) -> int: + _wire() + client = _Client() + seller = PlatformAwareSeller( + client, + _wallet(), + SimpleNamespace(calculate_priority_fee=_no_fee), + slippage=0.5, + max_retries=1, + ) + asyncio.run(seller.execute(_token_info(), token_price=PRICE, **kwargs)) + sell = next( + ix for ix in client.sent[0] if ix.program_id == PumpFunAddresses.PROGRAM + ) + return struct.unpack_from(" bool: + """D: sell_v2 sells amount_raw exactly; without it, the old floor stays.""" + exact = _sell_amount(token_amount=LOSSY_RAW / 10**6, token_amount_raw=LOSSY_RAW) + legacy = _sell_amount(token_amount=LOSSY_RAW / 10**6) + ok = exact == LOSSY_RAW and legacy == LOSSY_RAW - 1 + if not ok: + print(f" exact={exact} legacy={legacy}") + return ok + + +def check_e_trader_passes_raw() -> bool: + """E: both exit paths hand the raw amount on; the position keeps it.""" + source = inspect.getsource(universal_trader) + calls = source.count("self.seller.execute(") + with_raw = source.count("token_amount_raw=buy_result.amount_raw") + source.count( + "token_amount_raw=position.quantity_raw" + ) + keeps = "quantity_raw=buy_result.amount_raw" in source + ok = calls == 2 and with_raw == 2 and keeps + if not ok: + print(f" seller calls={calls} passing raw={with_raw} position keeps={keeps}") + return ok + + +class _ExactInBuilder: + """Stands in for LaunchLab's builder: exact-in, records what it is asked to spend.""" + + spends_exact_amount_in = True + + def __init__(self) -> None: + self.amount_in: int | None = None + + async def build_buy_instruction(self, _info, _user, amount_in, _min_out, _provider): + self.amount_in = amount_in + return [] + + def get_required_accounts_for_buy(self, *_a) -> list: + return [] + + def get_buy_compute_unit_limit(self, *_a) -> int: + return 100_000 + + +def check_f_exact_in_spend() -> bool: + """F: an exact-in buy spends the configured amount, and prices from it.""" + amount = 0.00013 # 130,000 lamports; as a float product, 129,999 + builder = _ExactInBuilder() + _buy(_Client(tokens_raw=1), amount=amount, builder=builder) + no_spend = _buy( + _Client(tokens_raw=LOSSY_RAW, spent=None), + amount=amount, + builder=_ExactInBuilder(), + ) + # Without the spend, the price must pair the submitted amount with the + # tokens received, not keep the pre-trade estimate. + price = (130_000 / 10**9) / (LOSSY_RAW / 10**6) + ok = ( + int(amount * 10**9) == 129_999 + and builder.amount_in == 130_000 + and abs(no_spend.price - price) < price * 1e-9 + ) + if not ok: + print( + f" float={int(amount * 10**9)} spent={builder.amount_in} " + f"price={no_spend.price} expected={price}" + ) + return ok + + +def main() -> int: + """Run every check and report.""" + checks = [ + ( + "A: the chosen raw count is lossy through a float", + check_a_round_trip_is_lossy, + ), + ("B: a landed buy reports the exact raw count", check_b_buy_reports_exact_raw), + ( + "C: the balance fallback reports a raw count", + check_c_balance_fallback_is_raw, + ), + ("D: the sell uses the exact raw count", check_d_sell_uses_exact_raw), + ("E: the trader passes the raw count to every sell", check_e_trader_passes_raw), + ( + "F: an exact-in buy spends and prices from its amount", + check_f_exact_in_spend, + ), + ] + passed = 0 + for label, check in checks: + ok = check() + print(f"{'PASS' if ok else 'FAIL'} {label}") + passed += ok + print(f"\n{passed}/{len(checks)} checks passed") + return 0 if passed == len(checks) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/regression/verify_exit_sell_confirmation.py b/tests/regression/verify_exit_sell_confirmation.py index 1fb8684..9fb9217 100644 --- a/tests/regression/verify_exit_sell_confirmation.py +++ b/tests/regression/verify_exit_sell_confirmation.py @@ -93,7 +93,11 @@ class StubSeller: self.attempts = 0 async def execute( - self, token_info: TokenInfo, token_amount: float, token_price: float + self, + token_info: TokenInfo, + token_amount: float, + token_price: float, + token_amount_raw: int | None = None, ) -> TradeResult: """Return the next scripted sell outcome. diff --git a/tests/regression/verify_time_based_exit_retry.py b/tests/regression/verify_time_based_exit_retry.py index 6b57f58..ade84e7 100644 --- a/tests/regression/verify_time_based_exit_retry.py +++ b/tests/regression/verify_time_based_exit_retry.py @@ -87,7 +87,11 @@ class StubSeller: prices_seen: list[float] = field(default_factory=list) async def execute( - self, token_info: TokenInfo, token_amount: float, token_price: float + self, + token_info: TokenInfo, + token_amount: float, + token_price: float, + token_amount_raw: int | None = None, ) -> TradeResult: self.prices_seen.append(token_price) if len(self.prices_seen) <= self.fail_first: diff --git a/tests/regression/verify_time_exit_without_price.py b/tests/regression/verify_time_exit_without_price.py index 33624ad..75eb340 100644 --- a/tests/regression/verify_time_exit_without_price.py +++ b/tests/regression/verify_time_exit_without_price.py @@ -98,7 +98,11 @@ class RecordingSeller: self.prices_seen: list[float] = [] async def execute( - self, token_info: TokenInfo, token_amount: float, token_price: float + 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. diff --git a/tests/regression/verify_tp_sl_exit_price.py b/tests/regression/verify_tp_sl_exit_price.py index 5d3bfa9..4b96ed7 100644 --- a/tests/regression/verify_tp_sl_exit_price.py +++ b/tests/regression/verify_tp_sl_exit_price.py @@ -89,7 +89,11 @@ class StubSeller: prices_seen: list[float] = field(default_factory=list) async def execute( - self, token_info: TokenInfo, token_amount: float, token_price: float + self, + token_info: TokenInfo, + token_amount: float, + token_price: float, + token_amount_raw: int | None = None, ) -> TradeResult: self.prices_seen.append(token_price) if len(self.prices_seen) <= self.fail_first: