Skip to content

fix: retire the per-id ledger on a capped close; expose range-end incarnation; pair range-end marks in the grader - #175

Merged
luisleo526 merged 3 commits into
mainfrom
fix/round4-candidate
Sep 3, 2026
Merged

fix: retire the per-id ledger on a capped close; expose range-end incarnation; pair range-end marks in the grader#175
luisleo526 merged 3 commits into
mainfrom
fix/round4-candidate

Conversation

@luisleo526

Copy link
Copy Markdown
Collaborator

Summary

Three fixes for pyramided strategies and the browser-export tapes, gated together against a like-for-like baseline.

  • F1 — capped strategy.close(id) retires the id's ledger. When the fill was capped by the position or a prior-bar reservation, the engine debited only the filled qty and re-credited the remainder on the next entry under that id; TradingView retires the id. xlm-grid: the 2026-02-06 14:30 close was 0.1099 = 0.1043 + 0.0056 carried (TV 0.1043) — the whole interior divergence of that probe. Same-bar-pending caps keep the old debit (unpinned against TV, documented).
  • F3 — range-end rows expose their entry incarnation across the C ABI. strategy_closed_trade_* accessors were bounded by trade_count() and read blank identities for the appended range-end rows, so the grader failed closed and the ena-grid (XAUUSD-1D) ladder rejected the TV-identical candidate. Now 25/25 matched, P&L equal to the cent.
  • Round-4 harness — range-end marks paired in the canonical grader. engine_trades.csv gains a 12th column Engine range-end (open on a range-end exit; registry parsers accept both widths, pineforge-workflow#19), the range-end regime applies to every tape with a range (feed bounded at TradingView's range end, rows written), and verify_corpus.py pairs engine marks with TradingView Open rows per lot (matched, exit/pnl ungated, cent-exact, outside the schedule aggregates). Browser-export lanes (AAPL, EURUSD, 227 ETH tapes) are scored on their marks for the first time.

Gate (like-for-like)

Because the harness scripts changed, the baseline was re-measured as 650b0cb's engine core + these scripts (base-round4-harness-20260903 → snapshot 1a633cec, itself +81 / −2 tiers vs the active baseline: AAPL +47, EURUSD +33; the only "worse" are the three grid bots at their honest values). Against that pin: PASS, hard 704 / 0 regressions, target +1 / −0; verdict 0056629e.

Test plan

  • ctest 152/152 (-UNDEBUG), python 102/102
  • spark pre-checks (F1+F3: 02-06 closes = TV on all three grid bots; ena-grid excellent; f-1d identical)
  • gate_candidate PASS recorded

🤖 Generated with Claude Code

https://claude.ai/code/session_01BXAE7TVTKZYbmtenHDHVJo

luisleo526 and others added 3 commits September 3, 2026 10:47
strategy_closed_trade_entry_incarnation bounded trade_index by
trade_count() == trades_.size(), but pf_report_t::trades is trades_
followed by range_end_trades_ (fill_trades_section), so every range-end
row — the position still open after the final bar, ABI v3's open_at_end —
read as out of range and returned 0. run_strategy.py then wrote an empty
"Engine entry incarnation" for the row, the grader failed closed on the
identity gate and the verifier ladder rejected the TV-identical trim
candidate for ena-grid (XAUUSD 1D), while the engine's per-lot range-end
rows themselves matched TV (xau-grid 6/6, silicon 9/9 rows). Round-4b F3.

The accessor now indexes the report's row space (report_trade_count /
get_report_trade on BacktestEngine); trade_count() / get_trade() stay the
Pine-visible closed trades. No struct changed, PF_ABI_VERSION stays 3.

Tests: the F3 discrimination lives in the C++ tests — test_c_abi_setters
pins a range-end row at index trade_count() (returns 0 on 650b0cb) and
test_range_end_close pin I runs a real open-at-end position and reads the
row's incarnation through the C ABI (range_end_inc == 0 on 650b0cb).
test_run_strategy_range_end adds a CSV-writer companion only: given a
populated entry_incarnation on an open_at_end row, the writer carries it
to the Entry line; it stubs the engine, never calls the accessor and
passes on the unpatched baseline, so it is not cited as the F3 pin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXAE7TVTKZYbmtenHDHVJo
A default-FIFO strategy.close(id) resolves its target as
min(id_unclosed_qty_[id], avail). When avail capped the target, only the
target was debited and the remainder was carried; the next entry under
the same id re-credited on top of the carry (execute_market_entry /
apply_entry fill: id_unclosed_qty_[id] += qty), so the next close(id)
over-closed by exactly the carry. TradingView retires every entry under
the id on a close(id) ("all entries with the ID are exited at once"): a
capped fill still retires the id. Round-4b F1.

Evidence: 3commas xlm-grid 2025-05-08 10:45 TP_L35 closes 0.0987 of an
L35 lot of 0.1043 on both engine and TV; on 2026-02-06 14:30 the engine
then closed 0.1099 = 0.1043 + 0.0056 carried while TV closed 0.1043
(nvdax: engine 0.0972 vs TV 0.0926). A Python port of the two ledger
rules reproduced the engine's closed qty byte-for-byte on 3/3 grid bots.

Both consumption paths now erase the id's ledger whole: the POOC flush's
sole-call branch (flush_active_same_bar_close) and
compute_close_target_qty's default-FIFO branch (immediate / deferred
closes). The latter reports the retired remainder so the two paths that
undo an unfilled close — the COOF reissue re-credit in strategy_close and
suppress_declined_reversal_close_legs (PendingOrder::
suppressed_close_retired_ledger_qty) — restore the exact pre-call
balance; suppressed_close_consumed_ledger_qty keeps its target value,
which the short-seed collision cohort reads as the materialized qty.
Deliberate partials (explicit qty / qty_percent, strategy.exit legs) never
touch the ledger and keep their semantics. The never-decaying close
reservations (F2) are out of scope and noted where F1 touches them.

The whole-retire is scoped to the cap the evidence pins: the position or
a PRIOR-BAR (persistent) reservation. A tokenized sole call whose
shortfall comes SOLELY from this bar's other pending callsites
(SameBarCloseCallsite::retire_ledger_whole == false, decided at admission
in enqueue_same_bar_close) keeps the pre-F1 debit-by-target rule, so it
stays continuous with the zero-target A/A->B oracle, which performs no
cleanup for that case; that rule is unpinned against TV. Token 0 has no
same-bar pending reservation and always retires whole.

Test: test_close_id_retires_ledger — A/A2: pyramided grid under POOC,
close(L35) capped 0.1043 -> 0.0987 by a standing reservation on L36,
re-entry L35 0.1043, next close(L35) closes exactly 0.1043 (0.1099 on the
unpatched 650b0cb engine, verified); the ledger is absent right after the
capped fill (was 0.0056). B: a deliberate partial close keeps its
remainder on both the POOC and the next-bar path. C: the deferred path —
a position-capped close(L) co-queued after a declined reversal is
suppressed and the ledger comes back at its exact pre-call balance
(target + retired), so the follow-up close(L) closes 100, not 60 (fails
3 checks with the retired term dropped). D: the COOF C-cursor fill-recalc
path under POOC — the immediate re-credit restores target + retired
(fails 4 checks with that term dropped). E: a tokenized sole call capped
solely by a same-bar sibling site keeps its carry (fails under an
unconditional whole-retire). No existing test pinned the carry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXAE7TVTKZYbmtenHDHVJo
… grade every tape bounded

A lot still open at the range's end is marked twice. TradingView's
browser export writes its exit row with Signal "Open", priced at the
range end's last close and netted of commission on both legs to the
cent (3commas-xlm-grid-bot #615: qty 0.0912, entry 2189.13, mark
2261.44 -> 6.3511, TV 6.35); the engine, fed to TradingView's range
end, books the same lot as its range-end close (open_at_end,
650b0cb) -- 6.351137 for that lot. The ws-report-v1 export prints the
same row unmarked, as a closed trade at the last bar's close, which is
exactly the engine's row.

Under v4 (650b0cb) the tape's spelling picked a regime: an unmarked
tape was bounded at TV's range end and the rows written; a marked tape
(227 of the 396 scraped ETH tapes) was measured as the baseline had
measured it -- feed unbounded three days past the range, the engine's
marks withheld -- because the canonical grader paired the baseline's
post-range closes with TV's Open rows on entry time, and the bounded
run (v2) moved pnlP90 on the three 3commas grid bots (0.5536 -> 0.6675
xlm, 0.5253 -> 0.6403 btcusdt-p, 0.1814 -> 0.9310 nvdax), which the
hard gate counts as a regression.

Round 3's instrumentation of the grader on spark (diag/r4, 2026-09-02;
every recorded engine_verify.json number reproduced to 4 decimals) put
the cause in the grader, not the engine. On the grid bots pnlP90 is not
a percentile: the fragmented-FIFO schedule path scores ONE aggregate,
|sum(tv) - sum(eng)| / |sum(tv)| over the raw rows, and the marks went
into it -- 22 TV Open rows at cents against 22 engine rows exact (21 of
22 agree to the cent; the 22nd, tv#614, is a FIFO lot-size split, per-
unit P&L identical). The baseline's 0.5536 was itself an artifact of
the unbounded feed: the engine's four post-range closes (+20.87, at
prices 2-4% above the mark) over-earned TV's three surviving marks
(+19.56) and cancelled a third of the real interior shortfall (+3.82 on
432.70 = 0.8822%), while 19 of 22 Open rows fell out of the window. The
interior sums are identical between the bounded and unbounded runs
(428.8824 vs 428.8825): the engine is byte-equivalent to the range end.

The grader now pairs the two marks of one lot (verify_corpus.py
pair_range_end_marks) before anything else looks at the rows. The
engine says which rows are marks: engine_trades.csv gains a trailing
column, "Engine range-end", "open" on the exit row of an open_at_end
trade and empty elsewhere (write_engine_trades_csv); TV says so with
Signal "Open". Marks pair with marks by the alignment every other trade
gets (align_by_time: same direction, entry within the window and the
price gate, closest entry first). A pair is a matched trade -- it
counts on both sides, it covers, its entry is gated -- and its exit and
P&L are compared nowhere that is gated: the rows leave both raw lists,
so no consolidation, alignment, trim window, schedule aggregate or
percentile (gated or report-only) sees a mark's cent-rounded P&L or
its exit; the P&L agreement is reported at TradingView's precision
instead (open_mark_pnl_cent_exact / open_mark_pnl_max_abs_usd). A pair
still anchors the common window, so a TV trade closed between the last
interior match and the open lots stays in it, unmatched, as before.
Unpaired marks are ordinary rows: a TV Open row the engine closed for
real earlier keeps its exit/pnl deltas, one the engine never opened
leaves the coverage denominator as before, and an engine range-end row
for a lot the tape closed is matched by entry and gated like any close.
A tape without Open rows, or an engine CSV without a marked row, pairs
nothing and grades as it did; the ws-report-v1 row pairs as the closed
trade it is. The former same-bar proxy (TV Open row + engine exit on
the same bar) is gone: the marker is the rule, and an engine close on
the last bar without it is a fill against a mark, gated.

The distinct-entry identity gate sees the marks as it saw them before
pairing existed: the keys TradingView proves to hold two physical
entries (distinct Signals at one time/price/direction) are read from
the tape BEFORE the marks pair off -- an Open row's entry is a fill
with a Signal like any other -- and distinct_entry_fill_mismatches
runs over the gate pools with the pairs added back, so the engine's
range-end row at a proven key must carry an entry incarnation exactly
as any close's entry must. Without that, a key proven by an A lot
closed and a B lot still open lost its proof when the B lot paired
off, and the engine's unidentified rows passed (strong -> excellent);
the same tape graded unpaired still refused it.

With the marks paired by the grader, the spelling no longer picks a
regime: run_strategy.py bounds every tape at TradingView's range end
(_load_tv_range_end: metrics.json wsProvenance.requestedRange.to / to
as 00:00 UTC, the tape's last row as the fallback, a probe's own
ohlcv_end_ms kept, no range and no rows left as given) and writes the
range-end rows marked. _withhold_open_at_end_trades is gone with the
regime; the log line names the bound, its source and how the tape
spells its row. On the diag set this grades alexgrover / aborkan89
(one Open row each) 100% at every p90 = 0 and pairs the grid bots'
22/22/28 lots exactly; their pnlP90 becomes the interior number (xlm
0.8822%, btcusdt-p 0.8458%, nvdax 1.4481% -> strong), above the
baseline's recorded artifact, which the hard gate compares against
verbatim -- that transition is the operator's call, recorded in the
campaign, not something a pairing rule can paper over.

Contract note: the registry's projection of engine_trades.csv
(pineforge-workflow campaign/src/lib-results.mjs parseEngineTrades and
airflow/pineforge_airflow/registry.py parse_engine_trades) pins the
header and the field count per line. Both parsers now accept this
12-column header alongside the 11-column one engines before it wrote
(the header fixes the width of every row), project the cell as
rangeEnd (blank -> null, "open" -> "open", anything else a parse
error) and carry rangeEnd null on the 11-column shape, so trade sets
already recorded and the sets this engine records both project; the
runner and Airflow ship with that parser before this engine is
measured. The lab's loaders (source_trade_provenance
load_verifier_engine_trades, verify-engine-local load_trades) and the
grader read by column name and take the column as is.

Tests: test_run_strategy_range_end.py -- the writer's column (header,
values on exit rows only, 12 fields per line, the grader reads it), one
regime for marked and unmarked tapes (same bytes out), the fallback and
no-range paths; test_verify_corpus_open_mark.py -- the pair (matched,
entry gated, exit/pnl not, P&L at cents), pairing on the lot not the
bar, the unpaired shapes on both sides, a legacy CSV without the
column, the ws tape, a one-lot tape, the grid-bot schedule
aggregate unchanged by exact and by plainly wrong marks, and the
identity gate: an A/B-signal key with the B lot open stays proven and
the paired mark is identity-checked (refused without an incarnation,
certified with one, the same verdict the tape gets unpaired).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXAE7TVTKZYbmtenHDHVJo
@luisleo526
luisleo526 merged commit 11e61d4 into main Sep 3, 2026
8 checks passed
@luisleo526
luisleo526 deleted the fix/round4-candidate branch September 3, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant