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:
Anton Sauchyk
2026-08-24 10:22:47 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent b367191267
commit a0540fdc9e
10 changed files with 549 additions and 13 deletions
@@ -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()))