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
+30
View File
@@ -191,6 +191,36 @@ with `BuybackFeeRecipientMissing` (6062) printed as confirmed buys.
and `str()` on it is empty, so the caller logs a blank reason. A slow
`getAccountInfo` is enough to take down a whole listener run this way.
### Verifying the tp/sl exit path (issue #189)
```bash
# Offline: the exit sell prices off the price that triggered it, a reverted
# exit sell is retried, and the retry is bounded
uv run learning-examples/verify_tp_sl_exit_price.py
```
`PlatformAwareSeller.execute` does not read a price — the `token_price` it is
handed **is** the slippage floor (`min_quote_output = amount * price *
(1 - slippage)`). So the caller owns the floor's correctness. A tp/sl exit fires
precisely because price left `entry_price`, so pricing the sell off the entry
sets a floor the pool cannot pay on a stop-loss and the sell reverts with 6003
`TooLittleSolReceived` — during the drop the stop-loss exists to escape. On a
take-profit the same mistake runs the other way and the floor protects nothing.
`_monitor_position_until_exit` already fetches `current_price` at the top of
each iteration, so passing it costs no extra RPC call; `_handle_time_based_exit`
genuinely has nothing fresher and keeps passing the buy price.
The seller's `max_retries` covers **transaction submission only**. An on-chain
revert comes back as `success=False` and is not retried there, so the retry has
to happen in the monitor loop, where the price is re-read first.
`trade.max_exit_sell_attempts` (default 3, validated to 1..100) bounds it so a
token that keeps reverting cannot pin the bot on one position, and the counter
resets if the price recovers out of the exit band. After the last attempt the
position is left open and unmonitored — logged loudly, since the tokens are
still held. Watch the `break`: before #189 it sat outside both branches of
`if sell_result.success:`, so a failed sell abandoned the position after a
single try while leaving `is_active=True`.
### Listener and decoder pitfalls
Each of these was a live bug in `learning-examples/`, all of them invisible