diff --git a/corpus b/corpus index 93d3e01..b4bcf59 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit 93d3e01eba345a1796cb02c899b36eb95dc66fd6 +Subproject commit b4bcf591118a9d5aa9531ca57ed8c11e5ba1f989 diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 016867f..fb81c20 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include "na.hpp" @@ -699,6 +700,12 @@ class BacktestEngine { int64_t position_cycle_seq_ = 0; int64_t next_position_cycle_seq_ = 1; std::vector pyramid_entries_; // individual entries for trade reporting + // Entry ids that have filled at least once in the CURRENT position cycle. + // TV keeps a from_entry bracket live for the life of the POSITION, not the + // life of its own entry leg: once the leg's units are FIFO-consumed by a + // sibling bracket, the leg still fires against the remaining position + // (thulashimohanr 06-17/10-14/01-14: Short's T2 closes a ShortAdd unit). + std::set cycle_filled_entry_ids_; // Per-entry-id UNCLOSED quantity ledger, used ONLY by strategy.close(id) // under the default FIFO close-entries rule to decide how much to close. // @@ -2355,7 +2362,8 @@ class BacktestEngine { private: enum class PositionReductionCause { - SCRIPT_ORDER, + SCRIPT_ORDER, // strategy.close / close_all / market exit / reversal + BRACKET_EXIT, // a strategy.exit bracket leg fill MARGIN_CALL, }; @@ -2375,18 +2383,28 @@ class BacktestEngine { void execute_partial_exit_qty( double fill_price, double qty_to_close, PositionReductionCause cause = PositionReductionCause::SCRIPT_ORDER); - void execute_partial_exit(double fill_price, double qty_percent); - void execute_partial_exit_by_entry(double fill_price, const std::string& from_entry); - void execute_partial_exit_by_entry_qty(double fill_price, - const std::string& from_entry, - double qty_to_close); - void execute_partial_exit_by_entry_percent(double fill_price, const std::string& from_entry, double qty_percent); + void execute_partial_exit( + double fill_price, double qty_percent, + PositionReductionCause cause = PositionReductionCause::SCRIPT_ORDER); + void execute_partial_exit_by_entry( + double fill_price, const std::string& from_entry, + PositionReductionCause cause = PositionReductionCause::SCRIPT_ORDER); + void execute_partial_exit_by_entry_qty( + double fill_price, + const std::string& from_entry, + double qty_to_close, + PositionReductionCause cause = PositionReductionCause::SCRIPT_ORDER); + void execute_partial_exit_by_entry_percent( + double fill_price, const std::string& from_entry, double qty_percent, + PositionReductionCause cause = PositionReductionCause::SCRIPT_ORDER); // KI-62: scratch (close dur-0) any same-bar same-id MARKET pyramid-add // slices still open after a from_entry priced bracket exit fills — TV's // open-tick fill sequence covered them. Targets only flagged same-bar add // slices (never the frozen pre-add lot, never a prior-bar slice). Returns // the qty scratched (0 = no collision → strict no-op). - double cover_samebar_market_adds_on_exit(const PendingOrder& order, double fill_price); + double cover_samebar_market_adds_on_exit( + const PendingOrder& order, double fill_price, + PositionReductionCause cause = PositionReductionCause::SCRIPT_ORDER); void cancel_oca_group(const std::string& oca_name, const std::string& exclude_id); // Pine v6 oca.reduce: when one sibling fills qty Q, reduce remaining // siblings' qty by Q. Siblings whose qty becomes <= 0 are cancelled. @@ -2574,12 +2592,17 @@ class BacktestEngine { bool closes_any_qty, double consumed_ledger_qty = std::numeric_limits::quiet_NaN()); + // cleared_leg_count_out: how many live EXIT legs carried this + // (id, from_entry) before the erase. TV re-issues MODIFY every live leg + // (each keeping its own entry binding) rather than collapsing them into + // one, so strategy_exit needs the census to re-arm the same multiplicity. void clear_existing_exit_order(const std::string& id, const std::string& from_entry, bool has_trail_request, int64_t& preserved_seq_out, uint64_t& replaced_incarnation_out, - double& preserved_reserved_qty_out); + double& preserved_reserved_qty_out, + int& cleared_leg_count_out); bool compute_exit_reserved_qty(const std::string& from_entry, double preserved_reserved_qty, double live_pos_qty, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index f29a166..7dc878c 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1081,7 +1081,6 @@ void BacktestEngine::revive_position_brackets_after_margin_call_partial( double margin_call_event_price) { const double mc_price = margin_call_event_price; if (position_side_ == PositionSide::FLAT) return; - static const std::string kClosePrefix = "__close__"; PendingOrder* marketable = nullptr; for (PendingOrder& o : pending_orders_) { if (o.type != OrderType::EXIT) continue; @@ -1089,12 +1088,11 @@ void BacktestEngine::revive_position_brackets_after_margin_call_partial( if (o.id.size() >= kClosePrefix.size() && o.id.compare(0, kClosePrefix.size(), kClosePrefix) == 0) continue; if (!o.dormant_bracket) continue; - bool bound = o.from_entry.empty(); - if (!bound) { - for (const auto& pe : pyramid_entries_) { - if (pe.entry_id == o.from_entry) { bound = true; break; } - } - } + // finding-347: mirror the dormancy predicate — position-cycle + // provenance, not bucket residency, so a leg orphaned by a sibling's + // FIFO drain revives with its siblings. + const bool bound = o.from_entry.empty() + || cycle_filled_entry_ids_.count(o.from_entry) != 0; if (!bound) continue; o.dormant_bracket = false; // Marketable at the margin-call event price? (Full-percent default @@ -4710,6 +4708,7 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric snapshot_entry_commission(materialized); pyramid_entries_.push_back(std::move(materialized)); id_unclosed_qty_[order.id] += qty; + cycle_filled_entry_ids_.insert(order.id); return; } @@ -4727,6 +4726,18 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric size_t trades_before_exit = trades_.size(); PositionSide side_before_exit = position_side_; + // finding-348: the pyramiding slot released by this reduction depends on + // WHICH exit retired the units. strategy.close / close_all materialise as + // EXIT orders carrying the kClosePrefix id stamp; every other EXIT order + // reaching this kernel is a strategy.exit bracket leg. That prefix is the + // only structural discriminator available here, and it is exact. + const bool is_bracket_exit = + order.type == OrderType::EXIT + && !(order.id.size() >= kClosePrefix.size() + && order.id.compare(0, kClosePrefix.size(), kClosePrefix) == 0); + const auto cause = is_bracket_exit ? PositionReductionCause::BRACKET_EXIT + : PositionReductionCause::SCRIPT_ORDER; + if (close_entries_rule_any_ && !order.from_entry.empty()) { // close_entries_rule="ANY": close only matching entries if (is_partial) { @@ -4738,28 +4749,43 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric // their percentage at fill time. if (has_explicit_qty_to_close) { execute_partial_exit_by_entry_qty( - fill_price, order.from_entry, order.qty); + fill_price, order.from_entry, order.qty, cause); } else { execute_partial_exit_by_entry_percent( - fill_price, order.from_entry, qp); + fill_price, order.from_entry, qp, cause); } } else { - execute_partial_exit_by_entry(fill_price, order.from_entry); + execute_partial_exit_by_entry(fill_price, order.from_entry, cause); } } else { if (dynamic_full_live_qty) { execute_market_exit(fill_price); } else if (has_explicit_qty_to_close) { - execute_partial_exit_qty(fill_price, order.qty); + execute_partial_exit_qty(fill_price, order.qty, cause); } else if (is_partial) { - execute_partial_exit(fill_price, qp); + execute_partial_exit(fill_price, qp, cause); } else { execute_market_exit(fill_price); } } + // 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). + // Consuming the id on the FIRST leg's fill would make the surviving sibling + // unre-issuable while the position is still open. Mark the id consumed only + // when the last leg carrying it is gone. if (order.requested_partial && trades_.size() > trades_before_exit) { - consumed_partial_exit_ids_.insert(order.id); + bool sibling_leg_still_live = false; + for (const PendingOrder& sibling : pending_orders_) { + if (sibling.type != OrderType::EXIT) continue; + if (sibling.incarnation == order.incarnation) continue; // self + if (sibling.id != order.id) continue; + if (sibling.from_entry != order.from_entry) continue; + sibling_leg_still_live = true; + break; + } + if (!sibling_leg_still_live) consumed_partial_exit_ids_.insert(order.id); } // KI-62: the normal close above drained only the frozen pre-add reserve @@ -4768,7 +4794,7 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric // covers it — scratch it dur-0 at the exit's fill price. A strict no-op // when no such add filled (the KEEP cell: the exit fills first, so the add // is not yet open here; and non-collision shapes flag no add slice). - double scratched = cover_samebar_market_adds_on_exit(order, fill_price); + double scratched = cover_samebar_market_adds_on_exit(order, fill_price, cause); // Full exit that closed the position: pending SAME-direction entries // placed on a different on_bar are cancelled for the rest of this @@ -4876,10 +4902,12 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price trail_best_price_ = fill_price; pyramid_entries_.clear(); id_unclosed_qty_.clear(); + cycle_filled_entry_ids_.clear(); pyramid_entries_.push_back({fill_price, current_bar_.timestamp, qty, order.id, bar_index_}); pyramid_entries_.back().entry_incarnation = order.incarnation; snapshot_entry_commission(pyramid_entries_.back()); id_unclosed_qty_[order.id] += qty; + cycle_filled_entry_ids_.insert(order.id); if (!std::isnan(order.stop_price) || !std::isnan(order.limit_price)) { set_entry_fill_excursion_masks(pyramid_entries_.back(), current_bar_, fill_price); } @@ -4936,6 +4964,7 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price // same-bar from_entry bracket exit can scratch them dur-0. pyramid_entries_.back().market_pyramid_add = !is_priced_entry; id_unclosed_qty_[order.id] += new_qty; + cycle_filled_entry_ids_.insert(order.id); if (is_priced_entry) { set_entry_fill_excursion_masks(pyramid_entries_.back(), current_bar_, fill_price); } @@ -4956,15 +4985,12 @@ void BacktestEngine::materialize_relative_exit_prices_for_live_position() { const double dir = (position_side_ == PositionSide::LONG) ? 1.0 : -1.0; for (auto& order : pending_orders_) { if (order.type != OrderType::EXIT) continue; - if (!order.from_entry.empty()) { - bool has_parent_entry = false; - for (const auto& pe : pyramid_entries_) { - if (pe.entry_id == order.from_entry) { - has_parent_entry = true; - break; - } - } - if (!has_parent_entry) continue; + // finding-347: position-cycle provenance, mirroring the eligibility + // gate — a leg whose bucket has been FIFO-drained is still live and + // still needs its ticks resolved against the position entry price. + if (!order.from_entry.empty() + && cycle_filled_entry_ids_.count(order.from_entry) == 0) { + continue; } if (std::isnan(order.limit_price) && !std::isnan(order.profit_ticks)) { order.limit_price = position_entry_price_ + dir * order.profit_ticks * syminfo_mintick_; @@ -5009,7 +5035,6 @@ void BacktestEngine::materialize_relative_exit_prices_for_live_position() { // re-credit). void BacktestEngine::suppress_declined_reversal_close_legs( const PendingOrder& declined_entry) { - static const std::string kClosePrefix = "__close__"; for (PendingOrder& co : pending_orders_) { if (co.suppress_as_declined_reversal_close) continue; // idempotent if (co.type != OrderType::EXIT) continue; @@ -5031,7 +5056,6 @@ void BacktestEngine::suppress_declined_reversal_close_legs( } void BacktestEngine::mark_position_brackets_dormant_on_declined_reversal() { - static const std::string kClosePrefix = "__close__"; if (position_side_ == PositionSide::FLAT) return; // The live position's entry ids (pyramid lots) — a bracket is "standing" // when its from_entry names one of them, or when it is a global @@ -5051,12 +5075,12 @@ void BacktestEngine::mark_position_brackets_dormant_on_declined_reversal() { || !std::isnan(o.trail_points) || !std::isnan(o.trail_price); if (!priced) continue; - bool bound = o.from_entry.empty(); - if (!bound) { - for (const auto& pe : pyramid_entries_) { - if (pe.entry_id == o.from_entry) { bound = true; break; } - } - } + // finding-347: a standing bracket is one whose from_entry filled in + // THIS position cycle (or a global from_entry-less exit) — not one + // whose bucket still holds units. A leg orphaned by a sibling's FIFO + // drain is still standing and must go dormant with its siblings. + const bool bound = o.from_entry.empty() + || cycle_filled_entry_ids_.count(o.from_entry) != 0; if (!bound) continue; o.dormant_bracket = true; } @@ -5324,20 +5348,30 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( } } - // Skip exit orders whose from_entry doesn't match any active entry id. - if (order.type == OrderType::EXIT && !order.from_entry.empty()) { - bool has_match = false; - for (const auto& pe : pyramid_entries_) { - if (pe.entry_id == order.from_entry) { - has_match = true; - break; - } - } - if (!has_match) { - // Cancel stale from_entry-bound exits so they cannot fire later - // against future positions with the same id. - return OrderEligibility::Remove; - } + // Cancel exit orders whose from_entry never filled in THIS position cycle. + // + // finding-347: liveness is POSITION-scoped, not entry-bucket-scoped. TV + // keeps a from_entry bracket alive for as long as the position lives; once + // a sibling bracket FIFO-consumes the leg's own units, the leg still fires + // and draws from the position-level queue. The direct proof is TV's + // cross-assigned exit labels at 2025-06-17 / 2025-10-14 / 2026-01-14, where + // `Short` + `ShortAdd` fill 2u each on one bar and the T1 pair drains both + // `Short` units: TV still fires BOTH T2 legs (`T2 Exit` closes a ShortAdd + // unit, `Add T1` closed a Short unit). Testing pyramid_entries_ residency + // instead Removed the orphaned `ShortT2` permanently, so the engine fired + // only 3 of 4 units, carried a phantom unit, and was never flat — which is + // also what made the 06-18 entry look like a pyramiding-cap case when it is + // a flat-reset case. from_entry decides only whether a leg is ALLOWED TO + // EXIST (its parent entry must have filled in this position), never which + // units it may take; the fill path already draws FIFO across buckets. + // + // The Remove path's original purpose — stale exits must not fire later + // against a FUTURE position reusing the id — is preserved exactly, because + // cycle_filled_entry_ids_ is cleared the moment the position goes flat + // (reset_position_state_to_flat / open_fresh_position / the RAW_ORDER open). + if (order.type == OrderType::EXIT && !order.from_entry.empty() + && cycle_filled_entry_ids_.count(order.from_entry) == 0) { + return OrderEligibility::Remove; } // Same-bar exit handling: TradingView evaluates priced exits (stop/limit/ diff --git a/src/engine_internal.hpp b/src/engine_internal.hpp index 759a811..cb90438 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -56,6 +56,14 @@ inline constexpr double kPathPosEps = 1e-12; inline constexpr double kSegmentDenomEps = 1e-15; inline constexpr double kPathTimeEps = 1e-12; +// The id prefix the engine stamps on every EXIT order materialised from a +// strategy.close / strategy.close_all instruction ("__close__" + target id, +// bare "__close__" for close_all). It is the ONLY structural marker that +// separates a close-path reduction from a strategy.exit bracket leg fill at +// the exit-fill site, and three call sites previously each carried their own +// function-local copy. One definition, so the predicate cannot drift. +inline const std::string kClosePrefix = "__close__"; + struct RetainedChildFreshParentOrderContext { bool enabled = false; bool broker_flat = false; diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index ea9ac1c..c734dc1 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -237,7 +237,8 @@ void BacktestEngine::execute_partial_exit_qty( } -void BacktestEngine::execute_partial_exit(double fill_price, double qty_percent) { +void BacktestEngine::execute_partial_exit(double fill_price, double qty_percent, + PositionReductionCause cause) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; double pct = std::clamp(qty_percent, 0.0, 100.0); @@ -250,12 +251,14 @@ void BacktestEngine::execute_partial_exit(double fill_price, double qty_percent) if (pct < 100.0 - kFullPercentEps) { qty_to_close = apply_exit_qty_step(qty_to_close); } - execute_partial_exit_qty(fill_price, qty_to_close); + execute_partial_exit_qty(fill_price, qty_to_close, cause); } // Internal helper: close only entries matching from_entry (close_entries_rule="ANY") -void BacktestEngine::execute_partial_exit_by_entry(double fill_price, const std::string& from_entry) { +void BacktestEngine::execute_partial_exit_by_entry(double fill_price, + const std::string& from_entry, + PositionReductionCause cause) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; const double qty_before = position_qty_; @@ -274,8 +277,7 @@ void BacktestEngine::execute_partial_exit_by_entry(double fill_price, const std: } pyramid_entries_ = std::move(remaining); - settle_position_after_partial_exit( - qty_before, PositionReductionCause::SCRIPT_ORDER); + settle_position_after_partial_exit(qty_before, cause); } @@ -284,7 +286,8 @@ void BacktestEngine::execute_partial_exit_by_entry(double fill_price, const std: // reservations into PendingOrder::qty; when layered siblings fill on one bar, // that absolute reservation must survive earlier reductions of the position. void BacktestEngine::execute_partial_exit_by_entry_qty( - double fill_price, const std::string& from_entry, double qty_to_close) { + double fill_price, const std::string& from_entry, double qty_to_close, + PositionReductionCause cause) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; if (!std::isfinite(qty_to_close) || qty_to_close <= kQtyEpsilon) return; const double qty_before = position_qty_; @@ -294,8 +297,7 @@ void BacktestEngine::execute_partial_exit_by_entry_qty( bool was_long = (position_side_ == PositionSide::LONG); fifo_drain(&from_entry, qty_to_close, fill_price, was_long); - settle_position_after_partial_exit( - qty_before, PositionReductionCause::SCRIPT_ORDER); + settle_position_after_partial_exit(qty_before, cause); } @@ -303,7 +305,8 @@ void BacktestEngine::execute_partial_exit_by_entry_qty( // close that quantity only from entries matching from_entry. void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price, const std::string& from_entry, - double qty_percent) { + double qty_percent, + PositionReductionCause cause) { if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return; double matched_qty = 0.0; @@ -316,7 +319,7 @@ void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price, double qty_to_close = matched_qty * (pct / 100.0); if (qty_to_close <= kQtyEpsilon) return; - execute_partial_exit_by_entry_qty(fill_price, from_entry, qty_to_close); + execute_partial_exit_by_entry_qty(fill_price, from_entry, qty_to_close, cause); } @@ -332,7 +335,8 @@ void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price, // covered slice as its own dur-0 trade (entry at the add's fill price, exit at // this exit's fill price), matching TV's per-pyramid scratch reporting. double BacktestEngine::cover_samebar_market_adds_on_exit(const PendingOrder& order, - double fill_price) { + double fill_price, + PositionReductionCause cause) { if (order.from_entry.empty()) return 0.0; if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return 0.0; // Scope to a PRICED bracket (stop/limit/trail). A plain market close / @@ -364,8 +368,7 @@ double BacktestEngine::cover_samebar_market_adds_on_exit(const PendingOrder& ord if (closed <= kQtyEpsilon) return 0.0; // nothing covered pyramid_entries_ = std::move(remaining); position_qty_ -= closed; - settle_position_after_partial_exit( - qty_before, PositionReductionCause::SCRIPT_ORDER); + settle_position_after_partial_exit(qty_before, cause); return closed; } @@ -604,6 +607,10 @@ void BacktestEngine::reset_position_state_to_flat() { trail_best_price_ = std::numeric_limits::quiet_NaN(); pyramid_entries_.clear(); id_unclosed_qty_.clear(); + // Bracket legs live for the POSITION cycle, so the provenance that keeps + // them alive dies exactly here — going flat is what makes a from_entry + // bracket stale and un-fireable against a future same-id position. + cycle_filled_entry_ids_.clear(); close_reserved_qty_.clear(); close_two_call_first_qty_.clear(); callsite_close_reserved_qty_.clear(); @@ -628,7 +635,19 @@ void BacktestEngine::settle_position_after_partial_exit( total_qty += pe.qty; } position_entry_price_ = weighted_sum / total_qty; - position_entry_count_ = (int)pyramid_entries_.size(); + // TV returns a pyramid slot when the entry is retired by a close-path + // 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 + // (thulashimohanr 2026-03-29: the 03-26 entry was fully retired by two + // T1 fills and TV still refused the third entry). + if (cause == PositionReductionCause::BRACKET_EXIT) { + position_entry_count_ = + std::max(position_entry_count_, (int)pyramid_entries_.size()); + } else { + position_entry_count_ = (int)pyramid_entries_.size(); + } // The one-contract floor-zero rule belongs to an otherwise unmodified // commissioned all-in short lifecycle. Any script-driven surviving // reduction changes that shape. Broker margin-call reductions are the @@ -672,6 +691,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri trail_best_price_ = fill_price; pyramid_entries_.clear(); id_unclosed_qty_.clear(); + cycle_filled_entry_ids_.clear(); close_reserved_qty_.clear(); close_two_call_first_qty_.clear(); callsite_close_reserved_qty_.clear(); @@ -681,6 +701,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri pyramid_entries_.back().entry_incarnation = entry_incarnation; snapshot_entry_commission(pyramid_entries_.back()); id_unclosed_qty_[id] += qty; + cycle_filled_entry_ids_.insert(id); } @@ -850,6 +871,7 @@ void BacktestEngine::add_to_pyramid_market(const std::string& id, bool is_long, // from_entry bracket exit; a priced pyramid add is not this collision. pyramid_entries_.back().market_pyramid_add = !is_priced_entry; id_unclosed_qty_[id] += new_qty; + cycle_filled_entry_ids_.insert(id); } diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 2aec225..01ef882 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -1476,9 +1476,10 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro int64_t discarded_seq = 0; uint64_t discarded_incarnation = 0; double discarded_reserved_qty = std::numeric_limits::quiet_NaN(); + int discarded_leg_count = 0; clear_existing_exit_order(id, from_entry, /*has_trail_request=*/false, discarded_seq, discarded_incarnation, - discarded_reserved_qty); + discarded_reserved_qty, discarded_leg_count); return; } bool has_explicit_qty = !std::isnan(qty); @@ -1517,13 +1518,17 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro int64_t preserved_seq = 0; uint64_t replaced_incarnation = 0; double preserved_reserved_qty = std::numeric_limits::quiet_NaN(); + int cleared_leg_count = 0; clear_existing_exit_order(id, from_entry, has_trail_request, preserved_seq, replaced_incarnation, - preserved_reserved_qty); + preserved_reserved_qty, cleared_leg_count); double reserved_qty = std::numeric_limits::quiet_NaN(); bool bind_global_full_exit_dynamic_qty = false; std::vector pooc_global_full_exit_bound_add_indices; + // Additional bracket legs beyond the primary one (see the leg-multiplicity + // block in the explicit-qty branch below). Empty on every other path. + std::vector extra_leg_qtys; if (has_explicit_qty) { // Honour the explicit qty literally (clamped to the live position // and subject to the same already-reserved accounting). This is @@ -1561,6 +1566,28 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro // Capacity then = open fills already tagged from_entry (same-id // pyramiding remainder) + the pending entry's qty (unbounded // when the entry's qty only resolves at fill time). + // + // A pending entry the pyramiding cap will REFUSE at fill opens no + // fills for a bracket to bind to. TV refuses such an entry at + // order-generation time, so the re-issue sees no pending entry at + // all. Mirror add_to_pyramid_market's fill-time gate (including + // its flat-armed / pre-armed-opposite priced exemptions) so the + // bracket sizes against the live position instead. + auto blocked_by_pyramiding_cap = [&](const PendingOrder& o) { + const PositionSide requested = + o.is_long ? PositionSide::LONG : PositionSide::SHORT; + if (position_side_ != requested) return false; // flip/reversal + const bool o_priced = !std::isnan(o.limit_price) + || !std::isnan(o.stop_price); + const bool flat_armed_priced = + o_priced && o.created_position_side == PositionSide::FLAT; + const bool pre_armed_opposite_priced = + o_priced + && o.created_position_side != PositionSide::FLAT + && o.created_position_side != requested; + if (flat_armed_priced || pre_armed_opposite_priced) return false; + return position_entry_count_ >= pyramiding_; + }; double capacity = live_pos_qty; bool entry_pending = false; double pending_entry_qty = 0.0; @@ -1568,6 +1595,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro if (o.id != from_entry) continue; if (o.type != OrderType::MARKET && o.type != OrderType::ENTRY && o.type != OrderType::RAW_ORDER) continue; + if (blocked_by_pyramiding_cap(o)) continue; entry_pending = true; if (std::isnan(o.qty)) { pending_entry_qty = std::numeric_limits::infinity(); @@ -1583,8 +1611,50 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro capacity = open_from_entry + pending_entry_qty; } double available = std::max(0.0, capacity - already_reserved); - reserved_qty = std::min(qty, available); + // LEG MULTIPLICITY. TV binds exit brackets to ENTRY INSTANCES via + // from_entry, not to the net position. A re-issue therefore + // MODIFIES every live leg carrying this exit id (each keeping its + // own binding) and ADDITIONALLY arms one new leg bound to the + // pending entry whose id == from_entry, when one exists. So an + // id can carry at most two bindings — the already-open fills and + // the pending entry — which also bounds a resting priced entry + // from growing a fresh leg on every bar's re-issue. + // + // thulashimohanr-prev-day-week-levels (ETH-USDT 15m, UTC), the + // three shapes this must reproduce simultaneously: + // + // 2025-06-29 09:30 carried 2u long (LongT1+LongT2 both live) + + // a pending same-id 2u entry -> 2 legs each -> + // the 14:30 stop @2441.78 closes FOUR units, + // tagged T1/T2/T1/T2. (Engine pre-fix: one leg + // per id, so the added entry stayed unhedged + // and survived to the next day's reversal.) + // 2026-03-27 09:30 ShortT1 was already consumed on 03-26, so it + // arms ONE leg (pending-entry binding only) and + // the 10:30 limit @2003.30 closes exactly 1u; + // ShortT2 still has a live leg -> 2 legs. This + // is the locus a blanket `qty * legs` multiply + // regresses, which is why the count is derived + // from live legs + pending entry, not from a + // multiplier. + // 2026-03-29 09:30 the third short is over pyramiding=2, so no + // admissible pending entry: ShortT1 (no live + // leg) finds available == 0 against the two + // live ShortT2 legs and arms nothing, while + // ShortT2 re-arms BOTH legs -> the 11:00 stop + // @2003.61 closes 2u, both tagged T2. + int leg_count = cleared_leg_count + (entry_pending ? 1 : 0); + leg_count = std::min(2, std::max(1, leg_count)); + const double total_reserved = + std::min(qty * (double)leg_count, available); + reserved_qty = std::min(qty, total_reserved); if (reserved_qty <= kQtyEpsilon) return; + double leg_remainder = total_reserved - reserved_qty; + while (leg_remainder > kQtyEpsilon) { + const double leg = std::min(qty, leg_remainder); + extra_leg_qtys.push_back(leg); + leg_remainder -= leg; + } } is_partial = reserved_qty < live_pos_qty - kFullQtyEps; } else { @@ -1820,7 +1890,33 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.comment = comment; order.created_while_in_position = !effectively_flat; - pending_orders_.push_back(std::move(order)); + if (extra_leg_qtys.empty()) { + pending_orders_.push_back(std::move(order)); + return; + } + + // Materialise the additional per-entry-instance bindings as real + // PendingOrders so each fires independently (and so BOTH the explicit-qty + // tally above and compute_exit_reserved_qty's already_reserved sweep count + // them, keeping sibling brackets correctly sized). The primary leg keeps + // the preserved queue position and replacement provenance; each extra leg + // is a genuinely new order with its own seq/incarnation, so the dispatch + // order stays deterministic and the KI-54 bracket-lifecycle bookkeeping + // never sees two orders claiming the same replaced incarnation. + pending_orders_.push_back(order); + for (double leg_qty : extra_leg_qtys) { + PendingOrder extra = order; + extra.qty = leg_qty; + extra.qty_percent = (live_pos_qty > kQtyEpsilon) + ? (leg_qty / live_pos_qty) * 100.0 + : order.qty_percent; + extra.requested_partial = leg_qty < live_pos_qty - kFullQtyEps; + extra.created_seq = next_order_seq_++; + extra.incarnation = next_order_incarnation_++; + extra.created_by_same_id_replacement = false; + extra.replaced_exit_order_incarnation = 0; + pending_orders_.push_back(std::move(extra)); + } } void BacktestEngine::strategy_cancel(const std::string& id) { @@ -2314,20 +2410,26 @@ void BacktestEngine::clear_existing_exit_order(const std::string& id, bool has_trail_request, int64_t& preserved_seq_out, uint64_t& replaced_incarnation_out, - double& preserved_reserved_qty_out) { + double& preserved_reserved_qty_out, + int& cleared_leg_count_out) { bool had_existing_order = false; preserved_seq_out = 0; replaced_incarnation_out = 0; preserved_reserved_qty_out = std::numeric_limits::quiet_NaN(); + cleared_leg_count_out = 0; for (const auto& o : pending_orders_) { if (o.type == OrderType::EXIT && o.id == id && o.from_entry == from_entry) { + ++cleared_leg_count_out; + if (had_existing_order) continue; + // The FIRST leg owns the queue position and the frozen + // reservation the caller carries forward; later legs are the + // additional per-entry-instance bindings (see strategy_exit). had_existing_order = true; preserved_seq_out = o.created_seq; replaced_incarnation_out = o.incarnation; if (!std::isnan(o.qty)) { preserved_reserved_qty_out = o.qty; } - break; } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7df8268..481f731 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,6 +40,7 @@ set(TEST_SOURCES test_strategy_oca test_oca_raw_pyramid_add test_strategy_pyramiding + test_pyramiding_count_partial_drain test_same_id_stop_replace test_exit_id_scoped_erase test_max_contracts_held @@ -94,6 +95,7 @@ set(TEST_SOURCES test_same_bar_add_exit_coverage test_declined_reversal_close_leg test_bracket_lifecycle_declined_reversal + test_exit_bracket_pending_entry_leg test_dual_entry_placement_sizing test_default_flat_market_gross_admission test_live_position_market_gross_admission @@ -120,6 +122,7 @@ set(TEST_SOURCES test_relative_exit_after_limit_parent test_short_seed_close_collision test_short_seed_collision_percent + test_exit_bracket_position_cycle_lifetime ) find_package(Threads REQUIRED) diff --git a/tests/test_exit_bracket_pending_entry_leg.cpp b/tests/test_exit_bracket_pending_entry_leg.cpp new file mode 100644 index 0000000..810654d --- /dev/null +++ b/tests/test_exit_bracket_pending_entry_leg.cpp @@ -0,0 +1,311 @@ +/* + * test_exit_bracket_pending_entry_leg.cpp — TV binds exit brackets to ENTRY + * INSTANCES via from_entry, not to the net position. + * + * A strategy.exit re-issue MODIFIES every live leg carrying that exit id (each + * keeping its own binding) and ADDITIONALLY arms one new leg bound to the + * pending entry order whose id == from_entry, if one exists at that moment. + * The engine armed exactly ONE order per exit id, so a re-issue that coincided + * with a pending same-id entry under-reserved: two qty=1 brackets covered only + * 2 units of a position that became 4. + * + * Ground truth — thulashimohanr-prev-day-week-levels-or-vwap-strategy, + * pyramiding=2, ETH-USDT-USDT 15m (all times UTC). The three shapes below must + * hold SIMULTANEOUSLY; each one falsifies a different naive rule. + * + * (i) 2025-06-29 — LEG MULTIPLICITY. A 2u long carried from 06-28 with both + * LongT1/LongT2 live; the 09:30 OR bar re-issues both brackets (stop + * re-priced to orLow(06-29)=2441.78) while a same-id 2u entry is pending. + * TV closes FOUR units at 2441.78 on the 14:30 stop, tagged T1/T2/T1/T2 — + * a full bracket pair PER ENTRY INSTANCE, carried pair first (FIFO): + * #153 T1 2441.78 q1 entry 2025-06-28 09:45 @2424.68 + * #154 T2 2441.78 q1 entry 2025-06-28 09:45 @2424.68 + * #155 T1 2441.78 q1 entry 2025-06-29 09:45 @2452.56 + * #156 T2 2441.78 q1 entry 2025-06-29 09:45 @2452.56 + * Engine pre-fix: only the carried pair closed; the added 2u collapsed + * into one unprotected trade that survived to the 06-30 reversal. + * + * (ii) 2026-03-27 — THE COUNTER-CASE that refutes a blanket multiply. Same + * shape (live carried Short bracket + pending same-id entry, both + * brackets re-issued in the same block) but ShortT1 had already been + * CONSUMED on 03-26 17:45, so it has no live leg and arms only the ONE + * pending-entry leg. TV fires a single T1 at 10:30 closing exactly 1 unit + * (the 03-26 remnant, FIFO). `reserved = qty * (open legs + pending + * entries)` would close 2 units at 2003.30 and desync the rest of March — + * a regression on a locus the engine already matches. Green both pre- and + * post-fix by construction: it is the guard, not the repro. + * + * (iii) 2026-03-29 — NO ADMISSIBLE PENDING ENTRY. The third short is over + * pyramiding=2 and TV rejects it, so the re-issue binds to nothing new: + * ShortT1 (no live leg, no admissible pending entry) arms NOTHING because + * the two live ShortT2 legs already reserve the whole position, and + * ShortT2 re-arms BOTH its legs. The 11:00 stop @2003.61 closes 2 units + * tagged "T2 Exit" / "T2 Exit" — the double-T2 label that only this model + * reproduces. Needs the pyramiding-count fix (an entry the cap will + * refuse at fill contributes no bracket leg). + */ + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) <= tol; +} + +namespace { + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk(double o, double h, double l, double c, int64_t ts) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1000.0; b.timestamp = ts; + return b; +} + +class BracketProbe : public BacktestEngine { +public: + BracketProbe() { + initial_capital_ = 1000000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 2.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + pyramiding_ = 2; + process_orders_on_close_ = false; + } + + std::string exit_id(int i) const { return closed_trade_exit_id(i); } + double entry_price(int i) const { return closed_trade_entry_price(i); } + double exit_price(int i) const { return closed_trade_exit_price(i); } + double size(int i) const { return closed_trade_size(i); } + double position_size() const { return signed_position_size(); } + + int rows_with_exit_id(const std::string& xid) const { + int n = 0; + for (int i = 0; i < trade_count(); ++i) { + if (closed_trade_exit_id(i) == xid) ++n; + } + return n; + } + +protected: + // The strategy's bracket pair: qty=1 T1/T2 legs sharing one stop, both + // attached to entry id "L" — the thulashimohanr shape. + void arm_brackets(double t1_limit, double t2_limit, double stop) { + strategy_exit("T1", "L", t1_limit, stop, kNaN, kNaN, kNaN, + 100.0, "", 1.0); + strategy_exit("T2", "L", t2_limit, stop, kNaN, kNaN, kNaN, + 100.0, "", 1.0); + } +}; + +// ── (i) 2025-06-29: a re-issue over a pending same-id entry arms both legs ── +// +// bar 0 entry L(2u) + T1/T2 (stop 90) [flat: one deferred leg each] +// bar 1 L fills @100 pos 2u [L:2] +// bar 2 entry L(2u) AGAIN + T1/T2 re-issued (stop 95) while L is pending +// -> T1: 1 live leg + 1 pending entry = 2 legs +// -> T2: 1 live leg + 1 pending entry = 2 legs +// bar 3 L#2 fills @110 pos 4u [L:2 @100, L:2 @110] +// bar 4 low 90 crosses the 95 stop -> ALL FOUR legs fire @95 +class CarriedPairProbe : public BracketProbe { +public: + void on_bar(const Bar& /*bar*/) override { + switch (bar_index_) { + case 0: + strategy_entry("L", true, kNaN, kNaN, 2.0); + arm_brackets(200.0, 300.0, 90.0); + break; + case 2: + strategy_entry("L", true, kNaN, kNaN, 2.0); + arm_brackets(210.0, 310.0, 95.0); + break; + default: break; + } + } +}; + +static std::vector carried_pair_bars() { + return { + mk(100, 101, 99, 100, 1000), // 0 + mk(100, 101, 99, 100, 2000), // 1 L fills @100 + mk(100, 101, 99, 100, 3000), // 2 re-issue over the pending L + mk(110, 111, 105, 110, 4000), // 3 L#2 fills @110 + mk(105, 106, 90, 95, 5000), // 4 stop 95 crossed + mk( 95, 96, 94, 95, 6000), // 5 + }; +} + +// ── (ii)+(iii): consumed leg, then a cap-refused entry ───────────────────── +// +// bar 0 entry L(2u) + T1(lim 110)/T2(lim 150), stop 80 +// bar 1 L fills @100 pos 2u [A:2] +// bar 2 high 111 -> T1 limit 110 fills 1u pos 1u [A:1] T1 CONSUMED +// bar 3 entry L(2u) + T1(lim 105)/T2(lim 150) re-issued while L is pending +// -> T1: 0 live legs + 1 pending entry = ONE leg <- (ii) +// -> T2: 1 live leg + 1 pending entry = two legs +// bar 4 L#2 fills @100 pos 3u [A:1, B:2] count 2 +// bar 5 high 106 -> T1 limit 105 fills exactly 1u (FIFO -> A's remnant) +// pos 2u [B:2]; entry count stays 2 (monotone) +// then: entry L(2u) + T1(lim 200)/T2(lim 250) re-issued, stop 95 +// -> the pending L is over pyramiding=2: no admissible pending entry +// -> T1: 0 live legs, and the two live T2 legs already reserve the +// whole 2u position -> NOTHING armed <- (iii) +// -> T2: 2 live legs, no pending entry -> BOTH re-armed +// bar 6 L#3's fill attempt is refused by the pyramiding cap pos 2u +// bar 7 low 90 crosses the 95 stop -> 2 units @95, BOTH tagged T2 +class ConsumedLegProbe : public BracketProbe { +public: + void on_bar(const Bar& /*bar*/) override { + switch (bar_index_) { + case 0: + strategy_entry("L", true, kNaN, kNaN, 2.0); + arm_brackets(110.0, 150.0, 80.0); + break; + case 3: + strategy_entry("L", true, kNaN, kNaN, 2.0); + arm_brackets(105.0, 150.0, 80.0); + break; + case 5: + strategy_entry("L", true, kNaN, kNaN, 2.0); + arm_brackets(200.0, 250.0, 95.0); + break; + default: break; + } + } +}; + +static std::vector consumed_leg_bars() { + return { + mk(100, 101, 99, 100, 1000), // 0 + mk(100, 101, 99, 100, 2000), // 1 L fills @100 + mk(100, 111, 99, 100, 3000), // 2 T1 limit 110 -> 1u + mk(100, 101, 99, 100, 4000), // 3 re-issue over the pending L + mk(100, 101, 99, 100, 5000), // 4 L#2 fills @100 + mk(100, 106, 99, 100, 6000), // 5 T1 limit 105 -> exactly 1u + mk(100, 101, 99, 100, 7000), // 6 L#3 refused by the cap + mk(100, 101, 90, 95, 8000), // 7 stop 95 crossed + mk( 95, 96, 94, 95, 9000), // 8 + }; +} + +} // namespace + +// ---- (i) four units exit at the stop, a full pair per entry instance ------- + +static void test_reissue_over_pending_entry_arms_a_pair_per_instance() { + std::printf("test_reissue_over_pending_entry_arms_a_pair_per_instance\n"); + CarriedPairProbe eng; + auto bars = carried_pair_bars(); + eng.run(bars.data(), (int)bars.size()); + + // TV's #153-#156: T1/T2 against the carried lot (FIFO first), then T1/T2 + // against the added lot. Pre-fix only the first two rows existed and 2 + // units survived unprotected. + CHECK(eng.trade_count() == 4); + CHECK(eng.rows_with_exit_id("T1") == 2); + CHECK(eng.rows_with_exit_id("T2") == 2); + for (int i = 0; i < eng.trade_count() && i < 4; ++i) { + CHECK(near(eng.size(i), 1.0)); + CHECK(near(eng.exit_price(i), 95.0)); + } + if (eng.trade_count() == 4) { + CHECK(eng.exit_id(0) == std::string("T1")); + CHECK(near(eng.entry_price(0), 100.0)); // carried lot + CHECK(eng.exit_id(1) == std::string("T2")); + CHECK(near(eng.entry_price(1), 100.0)); // carried lot + CHECK(eng.exit_id(2) == std::string("T1")); + CHECK(near(eng.entry_price(2), 110.0)); // added lot + CHECK(eng.exit_id(3) == std::string("T2")); + CHECK(near(eng.entry_price(3), 110.0)); // added lot + } + CHECK(near(eng.position_size(), 0.0)); // pre-fix: 2.0 survived +} + +// ---- (ii) a consumed leg arms ONE leg; the limit closes exactly 1 unit ----- +// ---- (iii) a cap-refused entry arms no new leg; both T2 legs re-price ------ + +static void test_consumed_leg_and_capped_entry() { + std::printf("test_consumed_leg_and_capped_entry\n"); + ConsumedLegProbe eng; + auto bars = consumed_leg_bars(); + eng.run(bars.data(), (int)bars.size()); + + // rows: T1@110 (1u), T1@105 (1u), then the 2-unit stop-out @95. + CHECK(eng.trade_count() == 4); + if (eng.trade_count() < 4) return; + + // (ii) the 2026-03-27 locus. ShortT1 had no live leg, so the re-issue arms + // exactly ONE leg and the limit touch closes exactly ONE unit — FIFO + // against the carried remnant. A blanket qty*(legs+pending) multiply would + // close 2 here. + CHECK(eng.exit_id(0) == std::string("T1")); + CHECK(near(eng.size(0), 1.0)); + CHECK(near(eng.exit_price(0), 110.0)); + CHECK(eng.exit_id(1) == std::string("T1")); + CHECK(near(eng.size(1), 1.0)); + CHECK(near(eng.exit_price(1), 105.0)); + CHECK(near(eng.entry_price(1), 100.0)); // the carried remnant, FIFO + + // (iii) the 2026-03-29 locus. The over-cap entry never fills, so no third + // lot appears; T1 arms nothing and BOTH surviving T2 legs fire at the stop. + CHECK(eng.exit_id(2) == std::string("T2")); + CHECK(near(eng.size(2), 1.0)); + CHECK(near(eng.exit_price(2), 95.0)); + CHECK(eng.exit_id(3) == std::string("T2")); + CHECK(near(eng.size(3), 1.0)); + CHECK(near(eng.exit_price(3), 95.0)); + CHECK(eng.rows_with_exit_id("T1") == 2); // never a third T1 + CHECK(eng.rows_with_exit_id("T2") == 2); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- rerun determinism (handle reuse) -------------------------------------- + +static void test_rerun_reproduces_the_leg_census() { + std::printf("test_rerun_reproduces_the_leg_census\n"); + CarriedPairProbe eng; + auto bars = carried_pair_bars(); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 4); + + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 4); + CHECK(eng.rows_with_exit_id("T1") == 2); + CHECK(eng.rows_with_exit_id("T2") == 2); + CHECK(near(eng.position_size(), 0.0)); +} + +int main() { + std::printf("=== test_exit_bracket_pending_entry_leg ===\n"); + + test_reissue_over_pending_entry_arms_a_pair_per_instance(); + test_consumed_leg_and_capped_entry(); + test_rerun_reproduces_the_leg_census(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return (tests_failed > 0) ? 1 : 0; +} diff --git a/tests/test_exit_bracket_position_cycle_lifetime.cpp b/tests/test_exit_bracket_position_cycle_lifetime.cpp new file mode 100644 index 0000000..2949384 --- /dev/null +++ b/tests/test_exit_bracket_position_cycle_lifetime.cpp @@ -0,0 +1,371 @@ +/* + * test_exit_bracket_position_cycle_lifetime.cpp — finding-347. A from_entry + * bracket leg lives for the POSITION cycle, not for its own entry bucket. + * + * Bug (pre-fix): classify_order_eligibility tested pyramid_entries_ RESIDENCY + * to decide whether a from_entry-bound exit was still live. When a sibling + * bracket FIFO-consumed all of a leg's own units, no pyramid entry carried that + * entry_id any more and the next eligibility pass Removed the leg permanently. + * The engine then fired 3 of 4 bracket legs, carried a phantom unit and never + * reached flat. + * + * TV's rule: from_entry decides only whether a leg is ALLOWED TO EXIST (its + * parent entry must have filled in this position cycle), never which units it + * may take — those come from the position-level FIFO queue. The engine's FILL + * path already drew FIFO across buckets; only the eligibility gate was + * bucket-scoped, which is what makes the fix surgical. + * + * Ground truth — thulashimohanr-prev-day-week-levels-or-vwap-strategy, + * ETH-USDT-USDT 15m (UTC). 2025-06-17 09:45 fills BOTH entry ids on one bar + * (`Short` 2u + `ShortAdd` 2u = 4 units, 4 bracket legs priced off the same + * 09:30 close). TV's CROSS-ASSIGNED exit labels are the direct proof: + * + * #127 06-17 14:45 T1 Exit 2525.91 q1 entry ▼ SHORT (Short's leg, Short unit) + * #128 06-17 14:45 Add T1 2525.91 q1 entry ▼ SHORT <- ShortAdd's leg took a Short unit + * #129 06-17 16:45 T2 Exit 2465.91 q1 entry ▼+ ADD <- Short's leg took a ShortAdd unit + * #130 06-17 16:45 Add T2 2465.91 q1 entry ▼+ ADD + * ==> 4 in, 4 out: TV is FLAT 16:45 + * + * The T1 pair drains BOTH `Short` units (engine agrees), orphaning `ShortT2`. + * TV still fires it at 16:45; the engine Removed it and fired only `ShortAddT2` + * — one unit instead of two. Identical at 2025-10-14 and 2026-01-14. Exactly 3 + * of the window's 12 two-id bars diverge: the 9 that exit all four legs on ONE + * bar match, because no eligibility pass runs inside the orphaning window. + * + * Fix: replace bucket residency with position-cycle provenance + * (cycle_filled_entry_ids_, cleared on flat / fresh open). + * + * A. the 06-17 shape — all FOUR units exit, 2 at the T1 price and 2 at the + * T2 price, including the ORPHANED bucket's leg; engine flat afterwards. + * (RED pre-fix: 3 rows, 1 phantom unit left open.) + * B. the 06-18 shape — because A now reaches flat, the next day's same-id + * entry is ADMITTED. This is the p1 (monotone pyramiding counter) + * interaction pin: p1 asks the right question, and only D3 gives it the + * right position to ask it about. + * C. negative — a leg whose from_entry NEVER filled in this cycle is still + * Removed (the gate still has teeth). + * D. the Remove path's original purpose survives: after a full close, a + * stale leg from the prior cycle does not fire against the new position. + */ + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) <= tol; +} + +namespace { + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk(double o, double h, double l, double c, int64_t ts) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1000.0; b.timestamp = ts; + return b; +} + +class CycleProbe : public BacktestEngine { +public: + CycleProbe() { + initial_capital_ = 1000000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 2.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + pyramiding_ = 2; + process_orders_on_close_ = false; + } + + std::string entry_id(int i) const { return closed_trade_entry_id(i); } + std::string exit_id(int i) const { return closed_trade_exit_id(i); } + double exit_price(int i) const { return closed_trade_exit_price(i); } + double size(int i) const { return closed_trade_size(i); } + double position_size() const { return signed_position_size(); } + + int rows_with_exit_id(const std::string& xid) const { + int n = 0; + for (int i = 0; i < trade_count(); ++i) { + if (closed_trade_exit_id(i) == xid) ++n; + } + return n; + } + int rows_at_exit_price(double px) const { + int n = 0; + for (int i = 0; i < trade_count(); ++i) { + if (near(closed_trade_exit_price(i), px)) ++n; + } + return n; + } +}; + +// ── A + B: the 2025-06-17 / 06-18 pair ──────────────────────────────────── +// +// bar 0 entry S(2u) + entry SA(2u), and all FOUR bracket legs: +// T1(from S, lim 96) T2(from S, lim 90) +// AT1(from SA,lim 96) AT2(from SA,lim 90) +// bar 1 BOTH entries fill @100 pos -4 [S:2, SA:2] count 2 +// bar 2 low 95 -> T1 and AT1 both fill @96, FIFO drains BOTH S units +// pos -2 [SA:2] +// -> the S bucket is now empty: pre-fix, T2 is Removed here +// bar 3 low 89 -> T2 AND AT2 fill @90 pos 0 FLAT +// T2's from_entry is "S" but the unit it takes is an SA unit — TV's +// cross-assigned "T2 Exit" on a ▼+ ADD entry. +// bar 4 entry S(2u) again — admitted, because bar 3 reached flat +// bar 5 it fills @90 pos -2 +// bar 6 close_all +// bar 7 it fills pos 0 +class TwoBucketProbe : public CycleProbe { +public: + void on_bar(const Bar& /*bar*/) override { + switch (bar_index_) { + case 0: + strategy_entry("S", false, kNaN, kNaN, 2.0); + strategy_entry("SA", false, kNaN, kNaN, 2.0); + strategy_exit("T1", "S", 96.0, kNaN, kNaN, kNaN, kNaN, + 100.0, "", 1.0); + strategy_exit("T2", "S", 90.0, kNaN, kNaN, kNaN, kNaN, + 100.0, "", 1.0); + strategy_exit("AT1", "SA", 96.0, kNaN, kNaN, kNaN, kNaN, + 100.0, "", 1.0); + strategy_exit("AT2", "SA", 90.0, kNaN, kNaN, kNaN, kNaN, + 100.0, "", 1.0); + break; + case 4: strategy_entry("S", false, kNaN, kNaN, 2.0); break; + case 6: strategy_close_all(); break; + default: break; + } + } +}; + +static std::vector two_bucket_bars() { + return { + mk(100, 101, 99, 100, 1000), // 0 + mk(100, 101, 99, 100, 2000), // 1 S + SA both fill @100 + mk(100, 101, 95, 96, 3000), // 2 T1 + AT1 @96 (drain the S bucket) + mk( 96, 97, 89, 90, 4000), // 3 T2 + AT2 @90 -> FLAT + mk( 90, 91, 89, 90, 5000), // 4 next-day entry signal + mk( 90, 91, 89, 90, 6000), // 5 it fills @90 + // The cycle-2 close_all deliberately settles at a level distinct from + // both bracket prices so the per-price row census stays unambiguous. + mk( 85, 86, 84, 85, 7000), // 6 close_all + mk( 85, 86, 84, 85, 8000), // 7 it fills @85 + mk( 85, 86, 84, 85, 9000), // 8 + }; +} + +// ── C: a leg bound to an entry id that never filled is still Removed ─────── +class GhostLegProbe : public CycleProbe { +public: + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ != 0) return; + strategy_entry("S", false, kNaN, kNaN, 2.0); + // "NEVER" is never issued as an entry: this leg must never fire, even + // though its limit is touched on bar 2. + strategy_exit("GHOST", "NEVER", 96.0, kNaN, kNaN, kNaN, kNaN, + 100.0, "", 1.0); + strategy_exit("REAL", "S", 90.0, kNaN, kNaN, kNaN, kNaN, + 100.0, "", 2.0); + } +}; + +// ── D: stale legs from a prior cycle do not fire against the new position ── +// +// Two variants: the new cycle re-uses the entry id, and the new cycle uses a +// different one. The "X" leg's limit (80) is only reachable in cycle 2. +class StaleLegProbe : public CycleProbe { +public: + explicit StaleLegProbe(bool reuse_id) : reuse_id_(reuse_id) {} + + void on_bar(const Bar& /*bar*/) override { + switch (bar_index_) { + case 0: + strategy_entry("S", false, kNaN, kNaN, 2.0); + strategy_exit("X", "S", 80.0, kNaN, kNaN, kNaN, kNaN, + 100.0, "", 2.0); + break; + case 2: strategy_close_all(); break; + case 4: strategy_entry(reuse_id_ ? "S" : "S2", false, kNaN, kNaN, + 2.0); break; + case 7: strategy_close_all(); break; + default: break; + } + } + +private: + bool reuse_id_; +}; + +static std::vector stale_leg_bars() { + return { + mk(100, 101, 99, 100, 1000), // 0 entry + the X bracket @80 + mk(100, 101, 99, 100, 2000), // 1 S fills @100 + mk(100, 101, 99, 100, 3000), // 2 close_all signalled + mk(100, 101, 99, 100, 4000), // 3 it fills @100 -> FLAT + mk(100, 101, 99, 100, 5000), // 4 cycle-2 entry signalled + mk(100, 101, 99, 100, 6000), // 5 it fills @100 + mk(100, 101, 79, 80, 7000), // 6 80 is touched — X must NOT fire + // The close_all settles at 85, clear of the stale leg's 80, so a row + // priced at 80 can only mean X fired. + mk( 85, 86, 84, 85, 8000), // 7 close_all signalled + mk( 85, 86, 84, 85, 9000), // 8 it fills @85 + mk( 85, 86, 84, 85, 10000), // 9 + }; +} + +} // namespace + +// ---- A: the orphaned bucket's leg still fires ------------------------------ + +static void test_orphaned_bucket_leg_still_fires() { + std::printf("test_orphaned_bucket_leg_still_fires\n"); + TwoBucketProbe eng; + auto bars = two_bucket_bars(); + eng.run(bars.data(), (int)bars.size()); + + // 4 units in, 4 units out on the two bracket bars, then the 06-18 cycle. + CHECK(eng.trade_count() == 5); + if (eng.trade_count() < 4) return; + + // T1 pair: both units come from the S bucket (position-level FIFO), and + // AT1 — whose from_entry is SA — legitimately takes one of them. That + // cross-bucket FILL already worked; it is the label TV shows as "Add T1". + CHECK(eng.exit_id(0) == std::string("T1")); + CHECK(eng.entry_id(0) == std::string("S")); + CHECK(near(eng.exit_price(0), 96.0)); + CHECK(eng.exit_id(1) == std::string("AT1")); + CHECK(eng.entry_id(1) == std::string("S")); + CHECK(near(eng.exit_price(1), 96.0)); + + // T2 pair. THE FIX: "T2" is bound to entry id S, whose bucket was fully + // drained on bar 2 — pre-fix it was Removed and this row did not exist. + // The unit it takes is an SA unit: TV's cross-assigned "T2 Exit" on a + // ▼+ ADD entry (#129). + CHECK(eng.exit_id(2) == std::string("T2")); + CHECK(eng.entry_id(2) == std::string("SA")); + CHECK(near(eng.exit_price(2), 90.0)); + CHECK(eng.exit_id(3) == std::string("AT2")); + CHECK(eng.entry_id(3) == std::string("SA")); + CHECK(near(eng.exit_price(3), 90.0)); + + CHECK(eng.rows_with_exit_id("T2") == 1); // the orphaned leg fired + CHECK(eng.rows_at_exit_price(96.0) == 2); + CHECK(eng.rows_at_exit_price(90.0) == 2); + for (int i = 0; i < 4; ++i) CHECK(near(eng.size(i), 1.0)); +} + +// ---- B: reaching flat re-admits the next cycle's same-id entry ------------- + +static void test_flat_readmits_next_cycle_entry() { + std::printf("test_flat_readmits_next_cycle_entry\n"); + TwoBucketProbe eng; + auto bars = two_bucket_bars(); + eng.run(bars.data(), (int)bars.size()); + + // Pre-fix the engine carried a phantom unit and never went flat, so the + // monotone pyramiding counter (p1) legitimately refused this entry — for a + // position TV does not have. With the leg lifetime fixed the engine is + // flat on bar 3, the counter resets, and the entry is admitted. + CHECK(eng.trade_count() == 5); + if (eng.trade_count() < 5) return; + CHECK(eng.entry_id(4) == std::string("S")); + CHECK(near(eng.size(4), 2.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- C: the gate still has teeth ------------------------------------------ + +static void test_leg_for_unfilled_entry_id_is_removed() { + std::printf("test_leg_for_unfilled_entry_id_is_removed\n"); + GhostLegProbe eng; + auto bars = two_bucket_bars(); + eng.run(bars.data(), (int)bars.size()); + + // GHOST's limit (96) is touched on bar 2, but "NEVER" never filled in this + // position cycle, so the leg is Removed and only REAL closes the position. + CHECK(eng.rows_with_exit_id("GHOST") == 0); + CHECK(eng.rows_at_exit_price(96.0) == 0); + CHECK(eng.trade_count() == 1); + if (eng.trade_count() < 1) return; + CHECK(eng.exit_id(0) == std::string("REAL")); + CHECK(near(eng.size(0), 2.0)); + CHECK(near(eng.exit_price(0), 90.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- D: a prior cycle's leg cannot fire against the new position ----------- + +static void test_stale_leg_does_not_fire_in_next_cycle() { + std::printf("test_stale_leg_does_not_fire_in_next_cycle\n"); + auto bars = stale_leg_bars(); + + // Different entry id in cycle 2: cycle_filled_entry_ids_ was cleared at + // flat and now holds only "S2", so the "S"-bound leg is Removed. + StaleLegProbe fresh_id(/*reuse_id=*/false); + fresh_id.run(bars.data(), (int)bars.size()); + CHECK(fresh_id.rows_with_exit_id("X") == 0); + CHECK(fresh_id.rows_at_exit_price(80.0) == 0); + CHECK(fresh_id.trade_count() == 2); + CHECK(near(fresh_id.position_size(), 0.0)); + + // Same entry id in cycle 2 — the harder case: provenance alone cannot + // distinguish the cycles, so this one is held by the full-close purge. + StaleLegProbe same_id(/*reuse_id=*/true); + same_id.run(bars.data(), (int)bars.size()); + CHECK(same_id.rows_with_exit_id("X") == 0); + CHECK(same_id.rows_at_exit_price(80.0) == 0); + CHECK(same_id.trade_count() == 2); + CHECK(near(same_id.position_size(), 0.0)); +} + +// ---- rerun determinism ----------------------------------------------------- + +static void test_rerun_reproduces_the_cycle_set() { + std::printf("test_rerun_reproduces_the_cycle_set\n"); + TwoBucketProbe eng; + auto bars = two_bucket_bars(); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 5); + + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 5); + CHECK(eng.rows_with_exit_id("T2") == 1); + CHECK(eng.rows_at_exit_price(90.0) == 2); + CHECK(near(eng.position_size(), 0.0)); +} + +int main() { + std::printf("=== test_exit_bracket_position_cycle_lifetime ===\n"); + + test_orphaned_bucket_leg_still_fires(); + test_flat_readmits_next_cycle_entry(); + test_leg_for_unfilled_entry_id_is_removed(); + test_stale_leg_does_not_fire_in_next_cycle(); + test_rerun_reproduces_the_cycle_set(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return (tests_failed > 0) ? 1 : 0; +} diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index 13659dd..adbb842 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -2124,15 +2124,16 @@ static void test_raw_short_add_invalidates_scoped_opening_event() { } // A genuine accepted same-direction add is itself a post-fill affordability -// event. FIFO then drains the original lot and makes the mutable entry count -// equal one again; the event must survive because it came from the accepted -// add directly, not from reconstructing provenance from the remaining count. +// event. FIFO then drains the original lot, leaving a single surviving pyramid +// leg; the event must survive because it came from the accepted add directly, +// not from reconstructing provenance from the remaining count or leg census. class AcceptedAddFifoProbe : public MCEngine { public: bool captured_after_open = false; bool eligible_after_add = false; bool eligible_after_fifo = false; int count_after_fifo = -1; + int legs_after_fifo = -1; AcceptedAddFifoProbe() { initial_capital_ = 1000.0; @@ -2163,11 +2164,15 @@ class AcceptedAddFifoProbe : public MCEngine { && opening_affordability_eligible_ && near(opening_affordability_raw_fill_base_, 100.0); - // FIFO removes the opening lot, leaving only ADD and restoring the - // mutable position_entry_count_ to one. The add event must stay live. + // FIFO removes the opening lot, leaving only ADD as a live pyramid + // leg. This drain is a CLOSE-PATH retirement (strategy.close), so TV + // hands the pyramid slot back and position_entry_count_ falls to one + // (a strategy.exit bracket drain would NOT release it — finding-348). + // The add event's liveness must not depend on either reading. strategy_close("OPEN", "fifo drain", /*qty=*/10.0, /*qty_percent=*/kNaN, /*immediately=*/true); count_after_fifo = position_entry_count_; + legs_after_fifo = (int)pyramid_entries_.size(); eligible_after_fifo = opening_affordability_pending_ && opening_affordability_eligible_ && near(opening_affordability_raw_fill_base_, 100.0); @@ -2184,6 +2189,10 @@ static void test_accepted_add_fifo_keeps_add_affordability_event() { CHECK(eng.captured_after_open); CHECK(eng.eligible_after_add); + // The close-path drain leaves ONE live pyramid leg AND returns the pyramid + // slot, so both readings are one. Neither is a usable provenance source + // for the affordability event — that is what this probe pins. + CHECK(eng.legs_after_fifo == 1); CHECK(eng.count_after_fifo == 1); // cannot reconstruct from this count CHECK(eng.eligible_after_fifo); // The one-shot event is consumed at the end-of-bar margin pass. diff --git a/tests/test_pyramiding_count_partial_drain.cpp b/tests/test_pyramiding_count_partial_drain.cpp new file mode 100644 index 0000000..11955f4 --- /dev/null +++ b/tests/test_pyramiding_count_partial_drain.cpp @@ -0,0 +1,376 @@ +/* + * test_pyramiding_count_partial_drain.cpp — `pyramiding` bounds the number of + * 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. + * + * Bug (pre-fix): settle_position_after_partial_exit() unconditionally + * re-derived position_entry_count_ from pyramid_entries_.size(). A + * strategy.exit bracket fill that DRAINED an entry leg while the position + * stayed open therefore handed the pyramid slot back, and the next + * same-direction market entry filled an add TradingView rejects. + * + * Ground truth (clause 4 — bracket drain PINS the slot) — + * thulashimohanr-prev-day-week-levels-or-vwap-strategy, pyramiding=2, + * ETH-USDT-USDT 15m (all times UTC): + * + * 2026-03-26 09:45 SHORT 2u @2082.49 entry #1 + * 2026-03-26 17:45 ShortT1 limit 2042.49 fills 1u (never flat) + * 2026-03-27 09:45 SHORT 2u @2043.29 entry #2 (TV admits) + * 2026-03-27 10:30 limit 2003.30 fills 1u = the 03-26 remnant + * -> the 03-26 leg drains; pyramid_entries_.size() 2 -> 1 + * 2026-03-29 09:45 SHORT would be entry #3 > pyramiding 2 TV REJECTS + * + * Both retirements of the 03-26 entry were `strategy.exit` T1 bracket fills, + * i.e. the entry was FULLY closed and TV still refused the third entry. + * + * The price gate on 2026-03-29 is unambiguously true (09:30 close 1997.67 < + * vwap 2003.236 and < orMid 2001.60) and the strategy.exit calls in the SAME + * if-block did execute (the carried stops re-armed from orHigh(03-27)=2051.11 + * to orHigh(03-29)=2003.61 and fired at 11:00). Pine ran the block; TV's + * broker emulator refused only the entry. Tape-wide rescan of + * 2025-03-31..2026-04-30 confirms 2026-03-29 is the ONLY day TV skipped a + * gate-satisfied OR entry, 15 entries occurred at streak=2 (all admitted) and + * ZERO at streak=3. + * + * Ground truth (clause 3 — close-path retirement RELEASES the slot) — + * 3commas-ena-grid-bot-long-strategy, pyramiding=200: 1021 entry fills over + * only 64 REUSED entry ids, 776 entries accumulated between two flats, and + * never more than 50 CONCURRENT open entries. Every exit is + * strategy.close("L"+i) — zero strategy.exit calls in the script. TV admits + * all 1021. A counter that never released would refuse 576 of them. The same + * shape holds for the xau grid (pyramiding=50, 48 levels, 839 units traded). + * + * Fix: settle_position_after_partial_exit() takes the reduction cause. Only + * PositionReductionCause::BRACKET_EXIT keeps the counter monotone + * (std::max against pyramid_entries_.size()); every other cause re-derives it + * from pyramid_entries_.size(). The cause is derived at the EXIT-order fill + * site from the kClosePrefix ("__close__") id stamp that strategy.close / + * close_all put on their materialised EXIT orders. + * + * A. locus-2 shape: entry A 2u, BRACKET partial exit 1u, entry B 2u + * (count=2), BRACKET partial exit drains A's remnant, third + * same-direction MARKET entry is REJECTED — no fill event, no trade row, + * position untouched. + * (RED pre-fix: the drain reset count to 1 and the third entry filled.) + * B. counterfactual: after a full close the counter resets and the very + * same entry call admits again. + * C. a partial exit that does NOT drain a leg is inert either way (the + * second entry still admits) — pins that the fix only bites on drain. + * D. the grid-bot clause: A's exact shape but the draining exit is + * strategy.close("A", qty=1) instead of a bracket leg — the slot IS + * returned and the third same-direction entry is ADMITTED. + * (RED under the unconditional-monotone rule: count stays 2 and the + * third entry is refused, which is the 3commas-ena regression.) + */ + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) <= tol; +} + +namespace { + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk(double o, double h, double l, double c, int64_t ts) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1000.0; b.timestamp = ts; + return b; +} + +class PyramidProbe : public BacktestEngine { +public: + PyramidProbe() { + initial_capital_ = 1000000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 2.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + pyramiding_ = 2; + process_orders_on_close_ = false; + } + + std::string entry_id(int i) const { return closed_trade_entry_id(i); } + std::string exit_id(int i) const { return closed_trade_exit_id(i); } + double size(int i) const { return closed_trade_size(i); } + double exit_price(int i) const { return closed_trade_exit_price(i); } + double position_size() const { return signed_position_size(); } + + // Count closed rows whose entry lot came from a given entry id. + int rows_for_entry(const std::string& id) const { + int n = 0; + for (int i = 0; i < trade_count(); ++i) { + if (closed_trade_entry_id(i) == id) ++n; + } + return n; + } +}; + +// ── A/B: the locus-2 trace (every reduction is a strategy.exit BRACKET) ─── +// +// X1/X2 are strategy.exit legs: their pending EXIT orders carry the plain ids +// "X1"/"X2", NOT the "__close__" stamp, so the fill site classifies both as +// PositionReductionCause::BRACKET_EXIT. The exit_id assertions below pin that +// — a rewrite that routed the drain through strategy.close would change them. +// +// bar 0 signal entry A (2u) +// bar 1 A fills @100 pos 2u [A:2] count 1 +// signal exit X1 from A, limit 110, qty 1 +// bar 2 X1 fills 1u @110 pos 1u [A:1] count 1 +// signal entry B (2u) +// bar 3 B fills @100 pos 3u [A:1, B:2] count 2 +// signal exit X2 from B, limit 120, qty 1 +// bar 4 X2 fills 1u @120 (FIFO -> drains A) BRACKET_EXIT +// pos 2u [B:2] count 2 (fixed) +// 1 (pre-fix) +// signal entry C (2u) <- must be REJECTED +// bar 5 C's fill attempt lands here +// bar 6 (flat_reset only) close_all signalled +// bar 7 close_all fills; signal entry D (2u) +// bar 8 D fills <- must be ADMITTED +// bar 9 close_all signalled +// bar 10 close_all fills +class DrainProbe : public PyramidProbe { +public: + explicit DrainProbe(bool flat_reset_tail) : flat_reset_tail_(flat_reset_tail) {} + + 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, + 100.0, "", 1.0); break; + case 4: strategy_entry("C", 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; + case 9: if (flat_reset_tail_) strategy_close_all(); break; + default: break; + } + } + +private: + bool flat_reset_tail_; +}; + +static std::vector drain_bars() { + return { + mk(100, 101, 99, 100, 1000), // 0 + mk(100, 101, 99, 100, 2000), // 1 A fills @100 + mk(100, 111, 99, 100, 3000), // 2 X1 limit 110 + mk(100, 101, 99, 100, 4000), // 3 B fills @100 + mk(100, 121, 99, 100, 5000), // 4 X2 limit 120 (drains A) + mk(100, 101, 99, 100, 6000), // 5 C's fill attempt + mk(100, 101, 99, 100, 7000), // 6 + mk(100, 101, 99, 100, 8000), // 7 + mk(100, 101, 99, 100, 9000), // 8 + mk(100, 101, 99, 100, 10000), // 9 + mk(100, 101, 99, 100, 11000), // 10 + }; +} + +} // namespace + +// ---- A: the drained pyramid slot is NOT handed back ------------------------ + +static void test_drained_leg_does_not_free_a_pyramid_slot() { + std::printf("test_drained_leg_does_not_free_a_pyramid_slot\n"); + DrainProbe eng(/*flat_reset_tail=*/false); + auto bars = drain_bars(); + eng.run(bars.data(), (int)bars.size()); + + // Only the two partial-exit rows exist. Entry C never filled: no fill + // event, no trade row, and the live position is still exactly B's 2 units. + CHECK(eng.trade_count() == 2); + CHECK(eng.entry_id(0) == std::string("A")); + CHECK(eng.exit_id(0) == std::string("X1")); + CHECK(near(eng.size(0), 1.0)); + CHECK(near(eng.exit_price(0), 110.0)); + CHECK(eng.entry_id(1) == std::string("A")); // FIFO drains A's remnant + // The draining exit is a strategy.exit BRACKET leg (no "__close__" stamp) + // -> BRACKET_EXIT -> the slot stays occupied. Scenario D is the same shape + // with a close-path drain and the opposite verdict. + CHECK(eng.exit_id(1) == std::string("X2")); + CHECK(near(eng.size(1), 1.0)); + CHECK(near(eng.exit_price(1), 120.0)); + CHECK(eng.rows_for_entry("C") == 0); + CHECK(near(eng.position_size(), 2.0)); // pre-fix: 4.0 +} + +// ---- B: a full close resets the counter; the same call then admits --------- + +static void test_flat_reset_readmits_the_entry() { + std::printf("test_flat_reset_readmits_the_entry\n"); + DrainProbe eng(/*flat_reset_tail=*/true); + auto bars = drain_bars(); + eng.run(bars.data(), (int)bars.size()); + + // X1(1u from A) + X2(1u from A) + close_all(2u from B) + close_all(2u from D) + CHECK(eng.trade_count() == 4); + CHECK(eng.rows_for_entry("C") == 0); // still rejected + CHECK(eng.entry_id(2) == std::string("B")); + CHECK(near(eng.size(2), 2.0)); + // D opened a fresh position after FLAT -> count reset to 1 -> admitted. + CHECK(eng.entry_id(3) == std::string("D")); + CHECK(near(eng.size(3), 2.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- C: a non-draining partial exit stays inert --------------------------- + +namespace { + +// Same shape, but the first partial exit takes only half of A's 2 units and +// the second entry follows immediately: no leg is ever drained, so the +// monotone counter and the size-derived counter agree throughout. +class NoDrainProbe : public PyramidProbe { +public: + 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 4: strategy_close_all(); break; + default: break; + } + } +}; + +} // namespace + +static void test_partial_exit_without_drain_is_inert() { + std::printf("test_partial_exit_without_drain_is_inert\n"); + NoDrainProbe eng; + auto bars = drain_bars(); + eng.run(bars.data(), (int)bars.size()); + + // A(2u) - 1u exit = 1u remnant, then B(2u) admits (entry #2 <= 2). + // close_all on bar 4 flushes both surviving lots at bar 5's open. + CHECK(eng.trade_count() == 3); + CHECK(eng.exit_id(0) == std::string("X1")); + CHECK(near(eng.size(0), 1.0)); + CHECK(eng.entry_id(1) == std::string("A")); + CHECK(near(eng.size(1), 1.0)); + CHECK(eng.entry_id(2) == std::string("B")); + CHECK(near(eng.size(2), 2.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- D: a CLOSE-PATH drain DOES free the pyramid slot --------------------- + +namespace { + +// ── D: scenario A's shape, with strategy.close doing the draining ───────── +// +// The grid-bot clause. 3commas-ena reuses 64 entry ids for 1021 fills and +// retires every one with strategy.close(); TV never refuses an entry even +// though 776 accumulate between flats under a cap of 200, because occupancy +// peaks at 50. A rule that pinned the slot on ANY reduction would refuse 576 +// TV-admitted entries. +// +// bar 0 signal entry A (2u) +// bar 1 A fills @100 pos 2u [A:2] count 1 +// signal exit X1 from A, limit 110, qty 1 (BRACKET, non-draining) +// bar 2 X1 fills 1u @110 pos 1u [A:1] count 1 +// signal entry B (2u) +// bar 3 B fills @100 pos 3u [A:1, B:2] count 2 +// signal strategy.close("A", qty=1) -> deferred EXIT "__close__A" +// bar 4 "__close__A" fills 1u @open 100 (FIFO -> drains A's remnant) +// pos 2u [B:2] count 1 +// signal entry C (2u) <- must be ADMITTED +// bar 5 C fills @100 pos 4u [B:2, C:2] count 2 +// bar 6 close_all signalled +// bar 7 close_all fills -> flat +class CloseDrainProbe : public PyramidProbe { +public: + 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_close("A", "drain", /*qty=*/1.0); break; + case 4: strategy_entry("C", true, kNaN, kNaN, 2.0); break; + case 6: strategy_close_all(); break; + default: break; + } + } +}; + +} // namespace + +static void test_close_path_drain_frees_a_pyramid_slot() { + std::printf("test_close_path_drain_frees_a_pyramid_slot\n"); + CloseDrainProbe eng; + auto bars = drain_bars(); + eng.run(bars.data(), (int)bars.size()); + + // X1(A 1u @110) + __close__A(A 1u @100) + close_all(B 2u) + close_all(C 2u) + CHECK(eng.trade_count() == 4); + + CHECK(eng.entry_id(0) == std::string("A")); + CHECK(eng.exit_id(0) == std::string("X1")); // bracket leg, no drain + CHECK(near(eng.size(0), 1.0)); + CHECK(near(eng.exit_price(0), 110.0)); + + // The DRAIN of A's remnant travels the close path: the materialised EXIT + // order carries the kClosePrefix stamp, so the reduction cause is + // SCRIPT_ORDER and the pyramid slot is returned. + CHECK(eng.entry_id(1) == std::string("A")); + CHECK(eng.exit_id(1) == std::string("__close__A")); + CHECK(near(eng.size(1), 1.0)); + CHECK(near(eng.exit_price(1), 100.0)); + + // The third same-direction entry is ADMITTED — this is exactly the cell + // the unconditional-monotone rule got wrong (RED there: 0 rows for C, + // trade_count 3, final position 2u). + CHECK(eng.rows_for_entry("C") == 1); + CHECK(eng.entry_id(2) == std::string("B")); + CHECK(near(eng.size(2), 2.0)); + CHECK(eng.entry_id(3) == std::string("C")); + CHECK(near(eng.size(3), 2.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +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(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return (tests_failed > 0) ? 1 : 0; +}