Repository navigation
Description
Summary
A strategy.exit call costs about 20 µs on every bar where the entry it names has no pending order and no open position. Nothing is placed and the trades do not change, but the time adds up over the bars: a script that calls its exits on every bar, as many published strategies do, takes 4.7 s where the same script takes 0.57 s when each exit is called only while its side is open.
The smallest case is a script whose only order call is one strategy.exit: 2.0 s for 105,136 bars, against 0.05 s for a script with no order call.
The time grows with the number of bars and with the number of such calls, and the scratch cache sizes do not change it.
Versions
pineforge-engine v1.12.0 (6e27d0f2) with pineforge-codegen 1.12.0, built from source as Release, Apple clang 17.0.0, macOS 15.8 arm64 (M1 Pro). Binance BTCUSDT 30m bars from 2020-01-01 to 2026-01-01 (105,136 bars), initial capital 10000, no commission or slippage, default scratch settings.
Time is the wall time of the strategy's run over the bars; translation, compilation and data loading are not included. One run at a time, median of 5.
Reproduction
The control calls each exit on the bar that places its entry and while its side is open:
//@version=6
strategy('Exits while their side is open', overlay=true, initial_capital=10000, default_qty_type=strategy.fixed, default_qty_value=0.01)
fast = ta.ema(close, 20)
slow = ta.ema(close, 60)
var float longStop = 0.0
var float longLimit = 0.0
var float shortStop = 0.0
var float shortLimit = 0.0
goLong = ta.crossover(fast, slow) and strategy.position_size == 0
goShort = ta.crossunder(fast, slow) and strategy.position_size == 0
if goLong
longStop := close * 0.97
longLimit := close * 1.06
strategy.entry('Long', strategy.long)
if goShort
shortStop := close * 1.03
shortLimit := close * 0.94
strategy.entry('Short', strategy.short)
if goLong or strategy.position_size > 0
strategy.exit('Long exit', 'Long', stop = longStop, limit = longLimit)
if goShort or strategy.position_size < 0
strategy.exit('Short exit', 'Short', stop = shortStop, limit = shortLimit)
The slow script is the same with the two if lines above the exits removed, so both exits are called on every bar:
strategy.exit('Long exit', 'Long', stop = longStop, limit = longLimit)
strategy.exit('Short exit', 'Short', stop = shortStop, limit = shortLimit)
Both give the same 526 trades and the same net profit (-360.24).
| Script | Time | Per bar | Trades |
|---|---|---|---|
No order call (plot(close) only) |
0.05 s | 0.5 µs | 0 |
One strategy.exit('Long exit', 'Long', stop = close * 0.97, limit = close * 1.06) and nothing else |
2.01 s | 19 µs | 0 |
The same and a second exit, 'Short exit' for 'Short' |
4.71 s | 44 µs | 0 |
| Control: each exit while its side is open | 0.57 s | 5.2 µs | 526 |
| Both exits on every bar | 4.69 s | 44 µs | 526 |
Control plus strategy.exit('Other exit', 'Other', stop = longStop, limit = longLimit) on every bar ('Other' is never entered) |
3.49 s | 32 µs | 526 |
On 5m bars over the same period (630,793 bars) the control takes 2.17 s and the script with both exits on every bar 20.97 s (891 trades each), and the one-exit script 12.37 s: the cost per bar stays the same.
With strategy_set_cache_policy_v1 giving kinds 4, 5, 6 and 12 4 MiB each, the 30m times are 0.66 s, 4.98 s and 2.02 s for the control, both exits on every bar and the one-exit script.
Where the time goes
macOS sample of the script with both exits on every bar, 5m bars, 6,713 samples in BacktestEngine::run:
- 42%:
PineExecutionAdapter::on_bar_open→erase_retired_rows→sweep_retired_rows→PlacementTable::erase→PlacementTable::change_sequences,SequenceIndex::remove,SequenceIndex::erase - 24%:
PineStrategyHost::strategy_exit→PineExecutionAdapter::exit→submit_or_replaceandremember_live→PlacementTable::try_emplace - By function on top of the stack:
memmove19.5%,PlacementTable::SequenceIndex::Key::byte16.5%,scratch_impl::Core::victim,fetchandhfind_leaf15% together,SequenceIndex::Key::read5.4%,pwrite3.8%
So each such call seems to add a placement row that the next bar open erases again (src/source/pine_adapter.cpp, src/source/pine_placement_retention.hpp). This is a profile, not a reading of the source.
Expected
A strategy.exit whose entry has no order and no position costs about as much as a call that is not made, so the script with both exits on every bar runs in about the control's time.
Workaround
Call each exit only on the bar that places its entry and while its side is open, as the control does. That is a change to the script, and it has to be checked against TradingView for each script, so it does not help when the script must run as published.
Activity
luisleo526 commentedon Nov 13, 2026
Thank you for the clear report, the minimal case and the control; the numbers and the setup make this easy to follow.
We will reproduce it on a Release build of v1.12.0 with your two scripts and bar count, on our own hardware, and report the measured times here. We will also check whether the per-bar cost comes from the same exit-arming path that another exit change currently touches, so that one change addresses both if it can.
Triage result (reproduced or not, and where the time goes) will be posted here by 2026-11-15.
🤖 Generated with Claude Code
luisleo526 commentedon Nov 14, 2026
Status 2026-11-14: the fix for this issue is in final verification (a lab run on the release build). It ships in PineForge 1.13.0 (release 2026-11-16), and this issue closes when it merges.
Merge #863 (train/engine-20261114b, #858) into the #864 train
luisleo526 commentedon Nov 14, 2026
Update 2026-11-14: a first change for this merged on main in ecc3348. When an exit names a
from_entrythat has no order or position, the engine no longer maintains the order-sequence index it does not use. Printed orders and trade records are identical before and after on all four cases we measured, and the sequence work on those cases drops to zero. It ships in PineForge 1.13.0 (release planned 2026-11-16).A correction to our status comment earlier today: this issue does NOT close on this merge. The change does not yet come with a speed measurement. We still owe you the measured times on a Release build with your two scripts and bar count, as promised in our triage comment, by 2026-11-15. The issue stays open until those numbers are posted here.
🤖 Generated with Claude Code
luisleo526 commentedon Nov 15, 2026
Measured times, as promised in our triage comment. Short version: the change merged in ecc3348 makes the scripts with exits about three times faster and the results are byte-identical, but an exit whose entry has no order and no position still costs about 9.5 µs per bar on main. That is not yet "about as much as a call that is not made", so this issue stays open while we remove the remaining cost.
Setup
6e27d0f2) and current main (5cf08f5c, which includesecc3348c), each built from source with the engine's default Release settings. The engine library is compiled as a default build compiles it; the tests and the tutorial were not built, the examples are off by default, and the small helper our benchmark runner needs was built.30m bars, default caches (median of 5)
plot(close)only)'Other exit'on every barOn 5m bars the picture is the same (one exit 20.0 s → 6.62 s, both exits on every bar 31.5 s → 11.2 s, control 3.84 s → 3.77 s), and the 4 MiB cache setting gives the same ratios (0.27 to 0.46 on the scripts with exits). Trades, equity and position traces are byte-identical between the two builds in every run.
What this means
Limits of this measurement
(Corrected 2026-11-15: an earlier version of this line said that a contention check during the run reported a busy interval. That result came from a deliberately constructed test of the check itself, not from the measurement runs.)