fix(trading): sell the exact raw token count the buy received (#245)

* 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
This commit is contained in:
Anton Sauchyk
2026-11-01 19:10:08 +01:00
committed by GitHub
parent b5d0fd7761
commit 503785feb5
10 changed files with 399 additions and 25 deletions
+1
View File
@@ -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 |
+3
View File
@@ -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
+52 -20
View File
@@ -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}")
+5
View File
@@ -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,
+6 -1
View File
@@ -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:
@@ -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("<Q", bytes(sell.data), 8)[0]
def check_d_sell_uses_exact_raw() -> 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())
@@ -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.
@@ -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:
@@ -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.
+5 -1
View File
@@ -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: