Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 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;
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) {
Expand Down Expand Up @@ -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_.size(); ++i) {
const auto& trade = trades_[i];
if (!order.from_entry.empty() && bound_lots_before == 1
&& trade.entry_id == order.from_entry) continue;
for (auto& pe : pyramid_entries_) {
if (pe.entry_incarnation == trade.entry_incarnation)
pe.bracket_slot_shadowed = true;
}
}
}

if (releasable_owned_slot != 0
&& position_side_ == side_before_exit
&& position_side_ != PositionSide::FLAT
&& position_entry_count_ == 2 && pyramid_entries_.size() == 1
&& pyramid_entries_[0].entry_incarnation != releasable_owned_slot
&& trades_.size() > trades_before_exit) {
bool only_owner_closed = true;
for (size_t i=trades_before_exit; i<trades_.size(); ++i) {
if (trades_[i].entry_incarnation != releasable_owned_slot) {
only_owner_closed = false;
break;
}
}
if (only_owner_closed) position_entry_count_ = 1;
}

// The one-shot guard belongs to the exit ID, but an id can carry more than
// one bracket leg (strategy_exit's per-entry-instance leg multiplicity: one
// binding for the already-open fills, one for a pending same-id entry).
Expand Down
7 changes: 5 additions & 2 deletions src/engine_orders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -764,9 +764,12 @@ void BacktestEngine::settle_position_after_partial_exit(
// order — the grid-bot family depends on it (3commas-ena: 1021 fills
// over 64 reused ids, 776 entries between flats under a cap of 200,
// never more than 50 CONCURRENT entries). TV does NOT return the slot
// when the entry is drained by strategy.exit bracket fills
// when a strategy.exit bracket drains another logical slot by FIFO
// (thulashimohanr 2026-03-29: the 03-26 entry was fully retired by two
// T1 fills and TV still refused the third entry).
// T1 fills and TV still refused the third entry). The narrowly proven
// unique-owner retirement in apply_exit_order_fill can release a slot
// after this conservative settlement; a prior foreign-bracket slice
// or ambiguous same-ID ownership remains pinned.
if (cause == PositionReductionCause::BRACKET_EXIT) {
position_entry_count_ =
std::max(position_entry_count_, (int)pyramid_entries_.size());
Expand Down
98 changes: 93 additions & 5 deletions tests/test_pyramiding_count_partial_drain.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
* OCCUPIED ENTRY SLOTS in the current directional position, tested at
* admission time. A slot is returned when the entry is retired by a CLOSE-PATH
* order (strategy.close / close_all / reversal / broker close) and is NOT
* returned when the entry is drained by a strategy.exit BRACKET leg fill.
* Reaching flat releases every slot.
* returned when another logical entry's BRACKET drains it by FIFO. R20 adds
* the proved ordinary two-distinct-ID exception: brackets fully retiring
* their own unique lot release its slot if no earlier foreign/ambiguous
* bracket slice shadowed it. Reaching flat releases every slot.
*
* Bug (pre-fix): settle_position_after_partial_exit() unconditionally
* re-derived position_entry_count_ from pyramid_entries_.size(). A
Expand Down Expand Up @@ -160,17 +162,22 @@ class PyramidProbe : public BacktestEngine {
// bar 10 close_all fills
class DrainProbe : public PyramidProbe {
public:
explicit DrainProbe(bool flat_reset_tail) : flat_reset_tail_(flat_reset_tail) {}
explicit DrainProbe(bool flat_reset_tail, bool owned_drain = false)
: flat_reset_tail_(flat_reset_tail), owned_drain_(owned_drain) {}
std::string third_id = "C";
int slots_after_drain = -1;

void on_bar(const Bar& /*bar*/) override {
switch (bar_index_) {
case 0: strategy_entry("A", true, kNaN, kNaN, 2.0); break;
case 1: strategy_exit("X1", "A", 110.0, kNaN, kNaN, kNaN, kNaN,
100.0, "", 1.0); break;
case 2: strategy_entry("B", true, kNaN, kNaN, 2.0); break;
case 3: strategy_exit("X2", "B", 120.0, kNaN, kNaN, kNaN, kNaN,
case 3: strategy_exit("X2", owned_drain_ ? "A" : "B", 120.0, kNaN, kNaN, kNaN, kNaN,
100.0, "", 1.0); break;
case 4: strategy_entry("C", true, kNaN, kNaN, 2.0); break;
case 4:
slots_after_drain = position_entry_count_;
strategy_entry(third_id, true, kNaN, kNaN, 2.0); break;
case 6: if (flat_reset_tail_) strategy_close_all(); break;
case 7: if (flat_reset_tail_) strategy_entry("D", true, kNaN, kNaN, 2.0);
break;
Expand All @@ -181,6 +188,7 @@ class DrainProbe : public PyramidProbe {

private:
bool flat_reset_tail_;
bool owned_drain_;
};

static std::vector<Bar> drain_bars() {
Expand Down Expand Up @@ -363,13 +371,93 @@ 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<int>(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<int>(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<int>(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<int>(bars.size()));
CHECK(reuse.slots==2);
reuse.cross_first=false;
reuse.run(bars.data(),static_cast<int>(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");

test_drained_leg_does_not_free_a_pyramid_slot();
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;
Expand Down
Loading