From 42321c44b286cc1846043f7a9a7667949bf1f491 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 5 Jul 2026 14:26:35 +0800 Subject: [PATCH 1/2] Reconcile layered strategy.exit reservations when the entry opens from flat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layered strategy.exit legs (a qty_percent<100 partial + a default/100% sibling on the same from_entry) armed on the entry-signal bar while the strategy.entry market order is still pending — position FLAT — both stored qty=NaN, because compute_exit_reserved_qty defers reservation when the position is flat and there was no fill-time reconciliation. At fill the 100% leg then closed the ENTIRE position instead of the remaining half (the engine emitted one trade with 2x the qty where TradingView emits two legs). On the FLAT->open entry fill, reconcile the deferred exit reservations for the from_entry group: only when it has >=2 pending exit legs and at least one is a partial, reserve each leg's floored percent slice in arm order and give the 100% sibling the remainder, freezing an explicit per-leg qty so each closes a fixed amount regardless of fill order — mirroring how TradingView binds each bracket leg to a slice of the entry. Lone brackets and pure 100% OCA TP/SL pairs are untouched (stay qty=NaN -> full remaining close). Corpus unchanged (excellent=229, ctest 78/78); fixes the 2x over-close on scale-out-on-entry-bar strategies. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013tuvZmenDcKuPu8LVqfqag --- include/pineforge/engine.hpp | 10 ++++++ src/engine_fills.cpp | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 3d28cab..810bcae 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1477,6 +1477,16 @@ class BacktestEngine { void apply_exit_order_fill(PendingOrder& order, double fill_price, int& exit_closed_from_bar, bool& exit_closed_was_long); + // Freeze the reserved qty of LAYERED strategy.exit legs (a qty_percent<100 + // partial + a sibling default/100% leg) that were armed while the position + // was FLAT (their entry still pending) and therefore stored qty=NaN. Called + // when such an entry first opens a position: each leg is bound to a fixed + // share of the just-opened lot so it no longer over-closes depending on + // sibling fill order. Mirrors TV binding each bracket leg to a fixed slice + // of the entry it attaches to. Only acts on multi-leg from_entry groups that + // contain at least one partial leg; single brackets and pure 100% OCA pairs + // are left untouched (qty=NaN → full remaining close, as before). + void reconcile_deferred_layered_exits(const std::string& entry_id); void apply_raw_order_fill(PendingOrder& order, double fill_price, double& trail_best_path_state, int& exit_closed_from_bar, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index eebc295..c482a9d 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -604,6 +604,19 @@ void BacktestEngine::apply_filled_order_to_state( double signed_pos_after = signed_pos(); double filled_qty = std::abs(signed_pos_after - signed_pos_before); + // This fill just opened a position from FLAT via an entry order. Freeze + // any LAYERED strategy.exit legs bound to that entry that were armed while + // flat (qty=NaN, reservation deferred): bind each to a fixed slice of the + // opened lot so a percent partial + its sibling 100% leg no longer + // over-close the whole position depending on which leg fills first. + if (std::abs(signed_pos_before) < kQtyEpsilon + && position_side_ != PositionSide::FLAT + && (order.type == OrderType::MARKET + || order.type == OrderType::ENTRY + || order.type == OrderType::RAW_ORDER)) { + reconcile_deferred_layered_exits(order.id); + } + if (position_side_ == PositionSide::FLAT) { trail_best_path_state = trail_best_price_; } @@ -863,6 +876,55 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric } } +void BacktestEngine::reconcile_deferred_layered_exits(const std::string& entry_id) { + if (entry_id.empty()) return; + const double live_pos = position_qty_; + if (live_pos <= kQtyEpsilon) return; + + // Only act on a LAYERED construct: a from_entry group with >=2 pending + // exit legs where at least one is a partial (qty_percent < 100). A lone + // bracket or a pure 100% OCA TP/SL pair carries no partial-vs-100% fill- + // order ambiguity and is left deferred (qty=NaN → full remaining close). + int leg_count = 0; + bool has_partial = false; + for (const auto& o : pending_orders_) { + if (o.type != OrderType::EXIT) continue; + if (o.from_entry != entry_id) continue; + ++leg_count; + double oqp = std::isnan(o.qty_percent) + ? 100.0 : std::clamp(o.qty_percent, 0.0, 100.0); + if (oqp < 100.0 - kFullPercentEps) has_partial = true; + } + if (leg_count < 2 || !has_partial) return; + + // Walk the group in arm (pending) order, reserving each leg's share of the + // opened lot exactly like compute_exit_reserved_qty would have if the + // position had been live at arm time: a partial reserves its floored + // percent slice; the 100% sibling reserves whatever remains. Freezing an + // explicit qty makes each leg close a fixed amount regardless of which + // fires first. Legs that already carry an explicit qty (reconciled at arm + // time) are left as-is but still consume reservation capacity. + double reserved = 0.0; + for (auto& o : pending_orders_) { + if (o.type != OrderType::EXIT) continue; + if (o.from_entry != entry_id) continue; + double oqp = std::isnan(o.qty_percent) + ? 100.0 : std::clamp(o.qty_percent, 0.0, 100.0); + if (!std::isnan(o.qty)) { // already reconciled at arm time + reserved += o.qty; + continue; + } + double avail = std::max(0.0, live_pos - reserved); + double requested = live_pos * (oqp / 100.0); + if (oqp < 100.0 - kFullPercentEps) requested = apply_exit_qty_step(requested); + double res = std::min(requested, avail); + if (res <= kQtyEpsilon) continue; // nothing left to reserve; leave deferred + o.qty = res; + o.requested_partial = res < live_pos - kFullQtyEps; + reserved += res; + } +} + void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price, double& trail_best_path_state, int& exit_closed_from_bar, From 9235f31c3c524b2b7861da2303ad26a9ad47ba29 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 5 Jul 2026 16:31:21 +0800 Subject: [PATCH 2/2] Update validation corpus warmup metadata --- corpus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corpus b/corpus index a84307d..70e1015 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit a84307d1afd9e02f6eb95b4ae6818ebae6652851 +Subproject commit 70e1015bc7223102d66ec3d20e7200ad888cd292