Margin checks at signal time vs fill time: a TradingView parity story
Our broker emulator ran the margin gate at fill time using next-bar open; TradingView runs it at signal time using current-bar close. The 3-cent gap between those prices silently dropped 25 trades from a 2,632-trade strategy. How six isolation probes triangulated the bug to one line of C++.
A community Pine strategy in our parity corpus
called IES had been sitting at moderate parity against TradingView for
weeks. Counts agreed. Entry prices agreed to the cent. Exit prices agreed
to the cent. But cumulative PnL drifted by $29,281 over 2,632 matched
trades.
This is the story of how we found the cause: a single margin check that ran four cents too late.
The asymmetric fingerprint
Here's what verify_corpus.py was reporting on the strategy before we
started digging:
community/IES (pre-fix)
Profile: strict
TV trades: 2668 (raw 2668)
Engine trades: 2663 (raw 2663)
Matched: 2632 (98.7% of TV)
Count delta: 0.1874% (OK)
Entry-price p90 delta: 0.0000% (OK)
Exit-price p90 delta: 0.0000% (OK)
PnL p90 delta: 2.5412% (X)
-> moderateThat row is diagnostic by itself. When count, entry price, and exit price all agree but per-trade PnL drifts by 2.5%, you are not looking at a signal-logic bug or a fill-price bug. Both engines pick the same trades at the same prices. They size them differently.
Working back from the last trade in the comparison window, the
back-solved sizing-input gap reproduced the $29,281 cumulative equity
difference exactly. The divergence lived in strategy.equity — the
recursive feedback variable that every position-sizing formula depends
on — not in any per-trade arithmetic.
What we tried first (and why none of it worked)
Before the right hypothesis we burned through three wrong ones. They are worth recounting because they are the realistic shape of debugging: most of the work is killing hypotheses, not confirming them.
Indicator warmup divergence. The engine warms its TA stack with five
months of OHLCV before the comparison window opens; TradingView's chart
history may not extend that far. We built a per-bar TA tracing harness
and found eight real indicator bugs in the process — ta.pivothigh /
ta.pivotlow semantics, ta.iii formula, ta.tsi codegen and scaling,
ta.cog weighting, ta.bbw percentage, ta.vwap daily anchor,
ta.supertrend.line, ta.pivot_point_levels previous-bar HLC, and
ta.cross skip-tie semantics. We fixed all eight. None of them moved
the IES needle.
Stop / limit fill timing. A different community strategy
(scalping-wunder-bots) had a clean pattern of the engine exiting one
bar earlier than TradingView. We hypothesized that the engine evaluated
strategy.exit stops on the entry bar while TV deferred to the next
bar. We built parity-probe-01-stop-limit-timing to isolate exactly
that. It returned 778 of 778 trades matched, all four strict gates
clean. The hypothesis was falsified.
CHoCH / BOS swing tracking. Another strategy (VCP) had one
spurious entry on 2025-05-12 12:15 that we suspected came from
divergent pivot-state evolution. We built
parity-probe-02-choch-bos-isolator mirroring its pivot signature
exactly. 1026 of 1026 matched. Falsified again.
Three probes in, we had no IES improvement and one piece of new methodology: writing minimal Pine strategies that isolate one hypothesis at a time was massively faster than tracing internal variables inside a 700-line community script. So we wrote a fourth.
The probe that found it
The trick with parity-probe-03-equity-mirror is that the order
quantity itself encodes strategy.equity:
//@version=6
strategy("Parity probe 03 - equity mirror",
shorttitle="par_p03", overlay=true, initial_capital=1000000,
default_qty_type=strategy.fixed, default_qty_value=1,
pyramiding=0, process_orders_on_close=false,
commission_value=0, slippage=0)
bool fire = dayofweek == 2 and hour == 0 and minute == 0
and strategy.position_size == 0
float qty_dyn = math.round(strategy.equity / close * 1000) / 1000
if fire and qty_dyn > 0
strategy.entry("E", strategy.long, qty=qty_dyn)
if strategy.position_size > 0 and bar_index >
strategy.opentrades.entry_bar_index(0)
strategy.close("E")qty = round(strategy.equity / close * 1000) / 1000 rounded to three
decimals means the exported Size (qty) column in TradingView's "List
of Trades" CSV becomes a per-bar oracle of TV's strategy.equity value.
Match engine and TV trades by entry timestamp, line up the qty columns,
and you read the equity divergence directly off the diff.
The first eight matched trades:
2025-03-31 00:15 eng_qty=553.1490 tv_qty=553.1490 diff=+0.000%
2025-04-14 00:15 eng_qty=612.9970 tv_qty=612.1600 diff=+0.137%
2025-04-21 00:15 eng_qty=623.1630 tv_qty=622.3110 diff=+0.137%
2025-08-25 00:15 eng_qty=207.2020 tv_qty=209.8110 diff=-1.244%
2025-11-03 00:15 eng_qty=242.4760 tv_qty=251.9860 diff=-3.774%
2025-11-24 00:15 eng_qty=339.1820 tv_qty=361.0170 diff=-6.048%
2025-12-22 00:15 eng_qty=307.0180 tv_qty=330.5680 diff=-7.124%
2026-01-05 00:15 eng_qty=295.2650 tv_qty=320.1020 diff=-7.759%Trade 1 matched perfectly. Both sides started with $1M of equity. From trade 2 onward the qty columns separated and never reconverged. The gap stabilised around 7.76% by trade 8.
The cause was not a calculation drift. It was a missed trade. The entry that would have been trade 2 — 2025-04-07 — appeared in TV's trade list but not in the engine's. TV took the trade and absorbed a $1,344 loss, its equity falling to roughly $984k. The engine never took the trade, so its equity stayed at roughly $985k. From that point on the engine was sized larger than TV on every subsequent trade, the cumulative losses diverged on different equity paths, and 13 months later the gap was $29,281.
A ghost fill
We added std::cerr logging to strategy_entry, process_pending_orders,
enter_market_from_flat, and apply_market_order_fill, all gated by an
environment variable so the noise didn't leak into other test runs.
At the signal bar, 2025-04-07 00:00:
[entry CALL] ts=1743984000000 bar=16236 id=E is_long=1 qty=627.312
pending_orders.size=0 pos_qty=0 pos_side=0strategy.entry was called. The order was queued. So far, fine.
At the fill bar, 2025-04-07 00:15:
[ppo] ts=1743984900000 bar=16237 pass=0 i=0 id=E type=0 is_long=1
qty=627.312 eligib=FILL_TRY
-> fill.kind=0 fill_price=1566.69
-> AFTER apply: pos_qty=0 pos_side=0 pyramid.size=0 trades.size=14fill.kind=0 is FillEvaluation::Kind::Fill. The price evaluator said
fill it. apply_filled_order_to_state was called. And the position
state did not update. A ghost fill.
One more debug line, this time inside enter_market_from_flat, printing
the inputs to the margin guard:
[margin] ts=1743984900000 qty=627.312 fill_px=1566.69 margin_pct=100
required=982803 available_equity=982785 net_profit_sum=-17215.4
initial=1e+06 block=YESRequired margin: $982,803. Available equity: $982,785. The margin guard wanted $18.91 more than the account had, returned early without updating any state, and produced no error and no public log line. From the strategy's perspective, the trade simply did not exist.
Why TradingView took the same trade
Here is the OHLC for the relevant bar pair:
04-07 00:00 (signal): O=1579.98 H=1585.57 L=1566.00 C=1566.66
04-07 00:15 (fill): O=1566.69The probe sized the order at signal close: qty = round(985319.43 / 1566.66 * 1000) / 1000 = 628.93. Required margin computed at signal
close exactly matches available equity. Required margin computed at the
next bar's open exceeds it by $18.91.
A three-cent move in ETH, multiplied by 628.93 contracts, produced $18.91 of additional required margin. The engine's margin check happened at fill time using the fill price — and rejected. TradingView's broker emulator, we now believed, ran the same check at signal time using the signal close — and accepted, because four cents on a $985k position is below whatever floating-point tolerance TV applies at the boundary.
Triangulating with three more probes
To prove this wasn't a one-trade coincidence — and that the bug was specifically about the 1× equity boundary — we built three more probes that bracket it:
Published all three to TradingView, exported the trade lists, ran the diff:
parity-probe-04: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0006% → excellent
parity-probe-05: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0698% → excellent
parity-probe-06: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0700% → excellentWhen sizing wasn't pinned to the 1× margin boundary, the engine and TV agreed perfectly across all 57 trades on each probe. The bug only manifested at the boundary, where slippage between signal close and fill open could flip the margin check.
The fix
Move the margin check from enter_market_from_flat (which runs at fill
time, using the fill price) to strategy_entry (which runs at signal
time, using the current bar's close):
void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
double limit_price, double stop_price,
double qty, ...) {
if (!trading_is_active(...)) return;
+
+ // TradingView broker rule: market-entry orders are admitted only
+ // when qty * <signal-bar close> * margin_pct/100 <= equity. Check
+ // happens HERE at signal time with current_bar_.close, NOT later
+ // in apply_market_order_fill where fill_price is the next bar's
+ // open. Verified empirically by parity-probe-{03..06}.
+ if (!std::isnan(qty) && std::isnan(limit_price) && std::isnan(stop_price)) {
+ double margin_pct = is_long ? margin_long_ : margin_short_;
+ if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) {
+ double required = std::abs(qty) * current_bar_.close * (margin_pct / 100.0);
+ double available = current_equity();
+ double epsilon = std::max(1e-9, std::abs(available) * 1e-12);
+ if (required > available + epsilon) {
+ return;
+ }
+ }
+ }
...Five lines, plus the corresponding deletion from
enter_market_from_flat. Re-run on IES:
community/IES (post-fix)
TV trades: 2668 (raw 2668)
Engine trades: 2643 (raw 2643)
Matched: 2618 (98.1% of TV)
Count delta: 0.9370% (OK)
Entry-price p90 delta: 0.0000% (OK)
Exit-price p90 delta: 0.0000% (OK)
PnL p90 delta: 0.7809% (OK) ← was 2.5412%
-> excellentPnL drift went from 2.49% to 0.78%. IES moved from moderate to
excellent. Across the rest of the corpus: 165 of 168 strategies at
excellent, 2 at strong, 0 at moderate. All 16 ctest binaries pass,
all 313 codegen pytest cases pass, no regressions on any of the other
161 community or validation strategies.
The plot twist
After the fix, parity-probe-03-equity-mirror itself still didn't
fully match TV. The engine took 25 entries, TV took 24, and only 13 of
those overlapped. Probe-03 is designed to live exactly on the 1× equity
boundary, so any tolerance disagreement between the two implementations
shows up there before anywhere else.
For each Monday in the comparison window we computed qty * signal_close - equity and recorded TV's accept-or-reject decision:
Aug 18 and Nov 10 carry identical +$0.24 overshoots and produce opposite outcomes. No pure margin-check rule — any threshold, any tolerance — can produce that pattern. TV's broker emulator at this boundary depends on something that isn't visible in any documented Pine variable: chart-load history, the order in which bars became visible to the strategy when the user published, or some hidden internal state we have no way to observe.
The honest read is that this is an undocumented corner in TradingView's broker emulator that only manifests when sizing is pinned exactly to the 1× equity boundary. Real strategies leave headroom — community/IES sizes at 1 to 1.4% of equity per trade — so this never bites real users. We chose to keep the fix and document probe-03 as a known limitation. The cosmetic tier downgrade on the diagnostic probe is a fair price for the IES win.
Six things this debugging story taught us
1. Asymmetric metric fingerprints diagnose the bug class. When count, entry price, and exit price all agree but PnL drifts, you are looking at a sizing bug. We should have looked at sizing-related code weeks earlier instead of auditing indicator formulas.
2. Isolation probes scale better than tracing big strategies. Six small Pine probes, each testing one hypothesis, found the bug in two afternoons. Tracing every internal variable in a 700-line community script with equity-feedback computation would have taken longer and produced more noise. This is the same reason we write unit tests instead of debugging only at the integration level.
3. TradingView's qty= parameter has signal-time margin semantics.
The check uses the bar at which strategy.entry is called, not the
fill bar. This isn't documented in Pine's reference. Anyone
implementing a Pine-compatible broker emulator should match it; the
natural implementation puts the check at fill time (where fill price
is known) and silently breaks.
4. Floating-point at boundaries is always the enemy. The single
trade that triggered $29k of compounded drift was a four-cent overshoot
on a $985k position. Production strategies should leave a 1 to 5%
margin buffer (a * 0.99 or * 0.95 in the sizing formula) to avoid
living on the boundary at all.
5. Compounding magnifies single-trade misses. The 04-07 miss alone was $1,344. Thirteen months later it had compounded into a $29,000 gap — roughly 22× the original miss — because each missed trade shifted the equity path, which changed the next sizing, which changed the next accept-or-reject decision. Equity-feedback strategies are unforgiving about isolated execution gaps.
6. Sometimes the bug is on the other side, and you document it honestly. TV's behavior at the 1× boundary is a real corner we can't fix from our side. Documenting it candidly beats silently chasing a metric that can't be matched.
The methodology generalises beyond this specific bug. Parity probes are the complement to our PyneCore cross-validation sweep: PyneCore tells us when our engine differs from another engine; parity probes tell us why it differs from TradingView. When we find a new gap, the playbook is now: write the smallest Pine script that exhibits it, publish to TV, diff the trade lists, instrument the engine, fix the disagreement, document any TV corner that can't be reconciled.
Where to go from here
- Try the codegen API from Claude or Cursor — transpile your
own Pine strategies and run them on local OHLCV. The
transpile_pinetool returns the C++ output if you want to see how the margin check is now wired. - Browse the gallery — all 162 strategies with their
current parity tiers. The community/IES card now shows
excellent. - Get early access — the free tier includes 100 transpiles per month, enough to start a local parity corpus of your own.