From bacbf4d0e977206268204d0fd14c35f02d54d8c8 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 7 Sep 2026 08:46:30 +0800 Subject: [PATCH 1/2] fix: release an owned bracket slot without erasing FIFO history --- include/pineforge/engine.hpp | 4 + src/engine_fills.cpp | 58 +++++++++++ src/engine_orders.cpp | 7 +- tests/test_pyramiding_count_partial_drain.cpp | 98 ++++++++++++++++++- 4 files changed, 160 insertions(+), 7 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 69a4294c..9304062c 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -195,6 +195,10 @@ struct PyramidEntry { // same physical-entry provenance. Zero is reserved for legacy/test-only // synthetic lots that were not created by a PendingOrder. uint64_t entry_incarnation = 0; + // A foreign/global or ambiguous same-ID bracket consumed part of this + // physical lot by FIFO. Its logical slot cannot later be released merely + // because an owner-bound bracket closes the last physical remainder. + bool bracket_slot_shadowed = false; }; struct Trade { diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 9931a7b1..a8979c01 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -6868,6 +6868,36 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric const auto cause = is_bracket_exit ? PositionReductionCause::BRACKET_EXIT : PositionReductionCause::SCRIPT_ORDER; + // R20 owner/FIFO contrast: with two distinct live entry IDs, a bracket + // that retires its own unique oldest lot releases that slot. A B-bound + // exit merely draining A by FIFO still occupies B's logical reservation + // and keeps the existing monotone count (the thula ETH March pin). + // Remember the exact physical owner, then prove its retirement below; + // no slot is returned for a partial slice or a different lot's closure. + uint64_t releasable_owned_slot = 0; + int bound_lots_before = 0; + if (is_bracket_exit && !close_entries_rule_any_ && pyramiding_ == 2) { + for (const auto& pe : pyramid_entries_) + if (pe.entry_id == order.from_entry) ++bound_lots_before; + } + if (is_bracket_exit && has_explicit_qty_to_close + && std::isfinite(order.qty) && order.qty > kQtyEpsilon + && !order.from_entry.empty() && !close_entries_rule_any_ + && !process_orders_on_close_ && !calc_on_order_fills_ + && !bar_magnifier_enabled_ && !coof_scheduler_active_ + && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE + && !sbmt_frozen_close && !dynamic_full_live_qty + && pyramiding_ == 2 && position_entry_count_ == 2 + && pyramid_entries_.size() == 2 + && pyramid_entries_[0].entry_id == order.from_entry + && !pyramid_entries_[0].bracket_slot_shadowed + && pyramid_entries_[1].entry_id != order.from_entry + && pyramid_entries_[0].entry_bar_index < bar_index_ + && pyramid_entries_[1].entry_bar_index < bar_index_ + && order.qty <= pyramid_entries_[0].qty + kQtyEpsilon) { + releasable_owned_slot = pyramid_entries_[0].entry_incarnation; + } + if (close_entries_rule_any_ && !order.from_entry.empty()) { // close_entries_rule="ANY": close only matching entries if (is_partial) { @@ -6906,6 +6936,34 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric } } + if (is_bracket_exit && !close_entries_rule_any_ && pyramiding_ == 2) { + for (size_t i=trades_before_exit; i trades_before_exit) { + bool only_owner_closed = true; + for (size_t i=trades_before_exit; i drain_bars() { @@ -363,6 +371,83 @@ static void test_close_path_drain_frees_a_pyramid_slot() { CHECK(near(eng.position_size(), 0.0)); } +// R20 covered TV owner/cross-owner contrast: an A-bound bracket retiring +// the unique A lot returns one slot while B remains. The original X2-from-B +// fixture above drains A by FIFO on behalf of B and must keep both slots. +static void test_owned_bracket_retirement_returns_slot() { + for (const std::string& id : {std::string("C"),std::string("A")}) { + DrainProbe eng(false, true); + eng.third_id=id; + auto bars=drain_bars(); + eng.run(bars.data(),static_cast(bars.size())); + CHECK(eng.slots_after_drain==1); + CHECK(eng.trade_count()==2); + CHECK(eng.entry_id(0)=="A" && eng.entry_id(1)=="A"); + CHECK(eng.exit_id(0)=="X1" && eng.exit_id(1)=="X2"); + CHECK(near(eng.position_size(),4.0)); + } +} + +class OwnedHistoryProbe : public PyramidProbe { +public: + bool cross_first=false; + bool is_long=true, full_first=false; + double first_qty=1, final_qty=1; + int slots=-1; + void on_bar(const Bar&) override { + switch(bar_index_) { + case 0: + slots=-1; + strategy_entry("A",is_long,kNaN,kNaN,2); + strategy_entry("B",is_long,kNaN,kNaN,2);break; + case 1: strategy_exit("X1",cross_first?"B":"A",is_long?110:90,kNaN,kNaN,kNaN,kNaN,100,"",first_qty);break; + case 3: + if(!full_first) strategy_exit("X2","A",is_long?120:80,kNaN,kNaN,kNaN,kNaN,100,"",final_qty); + break; + case 4: + slots=position_entry_count_; + strategy_entry("C",is_long,kNaN,kNaN,2);break; + } + } +}; +static void test_prior_cross_owner_slice_keeps_slot() { + for(bool cross : {false,true}) { + OwnedHistoryProbe p;p.cross_first=cross;auto bars=drain_bars(); + p.run(bars.data(),static_cast(bars.size())); + CHECK(p.slots==(cross?2:1)); + CHECK(near(p.position_size(),cross?2:4)); + CHECK(p.trade_count()==2); + CHECK(p.entry_id(0)=="A" && p.entry_id(1)=="A"); + } +} + +static void test_owned_slot_full_zero_and_reuse() { + for(bool is_long : {false,true}) { + auto bars=drain_bars(); + if(!is_long) for(auto& b:bars) { + const double hi=b.high,lo=b.low; + b.open=200-b.open;b.high=200-lo;b.low=200-hi;b.close=200-b.close; + } + for(bool zero_first : {false,true}) { + OwnedHistoryProbe p;p.is_long=is_long; + p.full_first=!zero_first;p.first_qty=zero_first?0:2; + p.final_qty=2;p.cross_first=zero_first; + p.run(bars.data(),static_cast(bars.size())); + CHECK(p.slots==1); + CHECK(near(p.position_size(),is_long?4:-4)); + CHECK(p.trade_count()==1); + CHECK(near(p.size(0),2)); + } + OwnedHistoryProbe reuse;reuse.is_long=is_long;reuse.cross_first=true; + reuse.run(bars.data(),static_cast(bars.size())); + CHECK(reuse.slots==2); + reuse.cross_first=false; + reuse.run(bars.data(),static_cast(bars.size())); + CHECK(reuse.slots==1); + CHECK(near(reuse.position_size(),is_long?4:-4)); + } +} + int main() { std::printf("=== test_pyramiding_count_partial_drain ===\n"); @@ -370,6 +455,9 @@ int main() { test_flat_reset_readmits_the_entry(); test_partial_exit_without_drain_is_inert(); test_close_path_drain_frees_a_pyramid_slot(); + test_owned_bracket_retirement_returns_slot(); + test_prior_cross_owner_slice_keeps_slot(); + test_owned_slot_full_zero_and_reuse(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return (tests_failed > 0) ? 1 : 0; From 6b1f4f12f4da40d172b54d41d81c7e8e84ef8d52 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 7 Sep 2026 09:01:58 +0800 Subject: [PATCH 2/2] docs: clarify which FIFO-drained pyramid slot stays occupied --- src/engine_fills.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index a8979c01..00c6f942 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -6870,8 +6870,8 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric // R20 owner/FIFO contrast: with two distinct live entry IDs, a bracket // that retires its own unique oldest lot releases that slot. A B-bound - // exit merely draining A by FIFO still occupies B's logical reservation - // and keeps the existing monotone count (the thula ETH March pin). + // exit merely draining A by FIFO leaves A's drained logical slot pinned + // while B still occupies its live slot (the thula ETH March pin). // Remember the exact physical owner, then prove its retirement below; // no slot is returned for a partial slice or a different lot's closure. uint64_t releasable_owned_slot = 0;