Skip to content

An unreachable strategy.close changes backtest results (60 trades, PnL 182.00) via script_has_strategy_close_ #141

Description

@h1rot

Hi — I think I have found something worth your attention, and it reproduces entirely inside
PineForge with no external comparison needed.

The observation

Take any strategy that uses priced (limit=) entries plus a strategy.exit bracket, and add
one line that can never execute:

if bar_index < 0
    strategy.close("L")

bar_index is never negative, so this call is unreachable. Yet on engine 0.12.2 /
codegen 0.10.1, CME ES continuous 1-minute, 86,124 bars, mintick=0.25 / pointvalue=50
supplied via PINEFORGE_SYMINFO:

without the dead strategy.close with it
trades 1,481 1,482
wins 785 787
Net PnL -10,402.00 -10,584.00
qty distribution {1.0: 1481} {1:1422, 2:53, 3:5, 4:1, 5:1}
trades with qty > 1 0 60

Adding unreachable code changes 60 position sizes and moves Net PnL by 182.00.

Why it happens

script_has_strategy_close_ is an AST scan, so mere textual presence of
strategy.close / strategy.close_all sets it — reachability is not considered. I confirmed
this with PINEFORGE_TRANSPILE_ONLY=1: the generated constructor contains no
script_has_strategy_close_ assignment for the baseline strategy, and
script_has_strategy_close_ = true; once the dead call is added.

The flag then gates the deferred-flip carry in src/engine_orders.cpp:

tv_deferred_flip = script_has_strategy_close_ && is_priced_entry
                && tv_carry_qty > 0.0 && (carry_was_long ? !is_long : is_long);
qty = tv_deferred_flip ? (tv_carry_qty + base_qty) : base_qty;

Conditions (b), (c) and (d) are satisfied by the strategy either way. Only (a) flips, and
with it 60 fills change size.

The question

The comment introducing the rule (around engine_orders.cpp:730-758) says the carry applies
when the original position "is later closed (by strategy.close, close_all, or any
exit
) and the priced entry now fires from FLAT". My strategy closes via a strategy.exit
bracket, which reads to me like "any exit" — but condition (a) admits only strategy.close.

So: is the gate meant to be a proxy for "this script can go flat by means other than the
bracket", or should a strategy.exit bracket fill arm the carry on its own?
Either way,
gating on textual presence of an unreachable call looks like something you would want to know
about.

Supporting evidence that the carry-armed behaviour is the TradingView-faithful one

This part does depend on an external comparison, so I flag its limits below.

I ran the same strategy on TradingView Desktop, CME_MINI:ES1! 15-minute, 439 closed trades:
430 at size 1, seven at size 2, one at size 3, one at size 4. Two identities hold on all 439
trades, so the sizes are real and not a reporting artifact:

  • commission == 2.25 * 2 * size (the size-4 trade shows commission 18.00 = 2.25 x 8 legs)
  • net_pnl == price_delta * 50 * size - commission

Eight of the nine oversized trades open at the exact timestamp the previous trade closed via
its own XL/XS bracket, and there is a visible runaway on 2026-06-04: size 2 at 20:45,
size 3 at 21:00, size 4 at 21:15. PyneCore 6.7.0 reproduces the same regime unprompted
(51 trades at size 2, 3 at size 3, out of 1,490).

So TradingView appears to arm the carry after a bracket close, which is the behaviour
PineForge produces only when the flag happens to be set.

Limits of this part: none of the three runs share data. The TradingView run is
CME_MINI:ES1! 15-minute 2025-09 to 2026-07; the PineForge and PyneCore runs are Databento
ES continuous 1-minute 2025 Q1. Different vendor, timeframe, calendar and roll convention. On
my account the TradingView ES 1-minute feed terminates at 2026-07-02, so I cannot run
TradingView on the same bars. I would not read the oversized rates across engines as
comparable; what I do consider solid is that TradingView produces sizes 2, 3 and 4 at all
under these settings.

For balance: on a separate divergence PineForge is the one that looks right

Of the 2,634.50 total P&L gap between PineForge and PyneCore at the correct symbol spec,
1,925.00 (73%) comes from 96 exit-fill price differences where PineForge is correct and
PyneCore is not
— PineForge applies the favourable open clamp max(limit, open) on a
gapped take-profit, while PyneCore reclassifies it to a market order and adds a tick of
adverse slippage. I measured TradingView's rule with a controlled slippage 0-vs-1 pair: of
17 trades, the 7 fills that moved were every one a stop-side exit and the 10 that did not
move were every one a limit-side exit, so slippage applies to stops and not limits on
TradingView. PineForge matches. I am raising that separately with PyneCore.

The size difference discussed above is 506.50 (19%) of that same gap. So this is not a
one-sided report.

Reproduction

Baseline and probe strategies plus both JSON reports are available; the only textual
difference between them is the three lines quoted at the top. Command used:

docker run --rm \
  -e PINEFORGE_SYMINFO=/in/syminfo.json \
  -v strategy.pine:/in/strategy.pine:ro \
  -v ohlcv.csv:/in/ohlcv.csv:ro \
  -v syminfo.json:/in/syminfo.json:ro \
  ghcr.io/pineforge-4pass/pineforge-release:engine0.12.2-codegen0.10.1

syminfo.json is {"syminfo": {"mintick": 0.25, "pointvalue": 50.0, "timezone": "UTC"}}.

Two minor container-interface notes

Both may well be intentional; they cost me time so I mention them in case they are not.

  1. PINEFORGE_SYMINFO is not wired in pineforge-engine:0.10.10run_json.py accepts
    --syminfo and calls apply_syminfo, but entrypoint.sh never passes it. It works in
    pineforge-release:engine0.12.2. Since the pineforge-engine GHCR package still tags
    latest at 0.10.10 and the move to pineforge-release appears only in release notes, I
    spent a while concluding symbol specs were unreachable before finding the newer image.
  2. margin_long / margin_short reach the generated constructor but are not accepted keys
    in set_strategy_override, so they cannot be changed via PINEFORGE_OVERRIDES.
    docs/pine_v6_audit_master.md does document them as compile-time only.

Happy to share any of the artifacts or run further A/Bs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions