diff --git a/corpus b/corpus index de734ef..256dc79 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit de734ef70d145c4a2fc8bb1554d53a835f77fff4 +Subproject commit 256dc793ef66a008aba19f166f777f9475b664a6 diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 10d4f75..226a990 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -2363,12 +2363,21 @@ class BacktestEngine { const PendingOrder& order) const; // TradingView binds a valid, single/full, non-trailing strategy.exit to a // co-queued high-level MARKET parent. If that parent fills at the next open - // and its stop is already breached, the newborn lot scratches at that open. + // and exactly one bracket leg is already marketable there — the stop + // breached, or the limit at-or-through the open — the newborn lot + // scratches at that open (duration-0, PnL-0). On a market REVERSAL parent + // this is the standing prior-bar strategy.exit whose levels were computed + // from the reversed-away position's avg price: TV honors that stale order + // at the fill bar's open instead of waiting for the re-priced bracket + // (rhyme17 finding 278 seed (b), six tape-proven limit-leg events). // The helper proves the parent/child/fresh-lot provenance; it deliberately - // excludes POOC, COOF, magnifier, same-direction adds, priced parents, and - // multi-child groups. - bool prearmed_market_parent_stop_gaps_at_open( - const PendingOrder& order, const Bar& bar) const; + // excludes POOC, COOF, magnifier, same-direction adds, priced parents, + // multi-child groups, and dual-marketable brackets. + // limit_leg (optional out): set true iff the LIMIT leg is the marketable + // one, so the fill site can take the unslipped limit-or-better path. + bool prearmed_market_parent_bracket_gaps_at_open( + const PendingOrder& order, const Bar& bar, + bool* limit_leg = nullptr) const; bool pending_flat_market_pair_is_live(const PendingOrder& order) const; void invalidate_pending_flat_market_pair(int64_t created_seq); void compact_filled_pending_orders(const std::vector& filled_indices, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 6efc47e..7f48c37 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -2450,18 +2450,37 @@ bool BacktestEngine::short_seed_collision_final_short_is_live( // A strategy.exit can be armed on the signal bar together with the MARKET // strategy.entry named by from_entry. The child is valid before the parent // fills: TradingView binds it to the eventual lot, and if the next open has -// already breached its stop, it fills both parent and child at that same open. -// Clean-room probe order-market-reversal-resting-bracket-gap-01 pins this for -// both directions and for parents placed from true flat or as reversals. +// already breached its stop OR reached its limit, it fills both parent and +// child at that same open. Clean-room probe +// order-market-reversal-resting-bracket-gap-01 pins the stop leg for both +// directions and for parents placed from true flat or as reversals. The +// LIMIT leg is pinned by finding 278 seed (b) on +// rhyme17-trendline-and-horizontal-breakout: on a reversal fill bar TV +// honors the STANDING prior-bar strategy.exit whose levels were computed +// from the OLD (reversed-away) position's avg price — a marketable-at-open +// limit fills AT THE OPEN, producing a duration-0 PnL-0 trade for the new +// position (six tape events: 2025-04-07/04-27/07-23/10-21/12-08/2026-01-09, +// each with entry px == exit px == bar open). The re-priced bracket the +// script issues at this bar's close then governs subsequent bars. +// +// SCOPE NOTE (ycelestine ledger): this helper changes exit ORDER lifecycle +// only — when a standing strategy.exit order is allowed to fill on the +// parent's fill bar. It does NOT touch the (reverted, off-limits) same-bar +// position_size VISIBILITY class: what the script observes as +// strategy.position_size mid-bar is unchanged, as are the #146 same-tick +// close+reverse sequencing kernel and ordinary non-reversal exit re-issues +// (those fail the position_open_bar_ / fresh-lot provenance below). // // Do not turn this into a general entry-bar wrong-side bypass. The exact // provenance below keeps freshly emitted/stale exits, priced parents, MARKET // pyramid adds, partial/sibling groups, POOC, COOF, and magnifier on their // existing paths. Generated Pine already lowers flat // strategy.position_avg_price to na, so an avg-derived flat bracket never -// reaches this helper with a finite stop. -bool BacktestEngine::prearmed_market_parent_stop_gaps_at_open( - const PendingOrder& order, const Bar& bar) const { +// reaches this helper with a finite leg. +bool BacktestEngine::prearmed_market_parent_bracket_gaps_at_open( + const PendingOrder& order, const Bar& bar, + bool* limit_leg) const { + if (limit_leg != nullptr) *limit_leg = false; if (process_orders_on_close_ || calc_on_order_fills_ || bar_magnifier_enabled_) { return false; } @@ -2473,27 +2492,28 @@ bool BacktestEngine::prearmed_market_parent_stop_gaps_at_open( || order.created_during_coof_recalc || order.requested_partial || order.qty_percent < 100.0 - kFullPercentEps - || !std::isfinite(order.stop_price) + || (!std::isfinite(order.stop_price) + && !std::isfinite(order.limit_price)) || !std::isnan(order.trail_points) || !std::isnan(order.trail_price)) { return false; } + // Exactly ONE marketable leg at the open. Test the actual W0 broker + // predicate: equality is marketable, and slippage can make the booked + // entry price differ from the bar open. Dual-marketable brackets stay + // out until a dedicated priority oracle exists (no tape exemplar); a + // bracket with neither leg marketable keeps the ordinary entry-bar + // path walk / wrong-side gating. const bool live_long = position_side_ == PositionSide::LONG; - const bool stop_gapped = live_long ? bar.open <= order.stop_price - : bar.open >= order.stop_price; - if (!stop_gapped) return false; - - // The oracle has a correctly-sided, nonmarketable limit sibling inside the - // same bracket, not a second open-gap leg. Keep dual-marketable brackets - // out until a dedicated priority oracle exists. Test the actual W0 broker - // predicate: equality is marketable, and slippage can make the booked entry - // price differ from the bar open. - if (!std::isnan(order.limit_price)) { - const bool limit_gapped = live_long ? bar.open >= order.limit_price - : bar.open <= order.limit_price; - if (limit_gapped) return false; - } + const bool stop_gapped = std::isfinite(order.stop_price) + && (live_long ? bar.open <= order.stop_price + : bar.open >= order.stop_price); + const bool limit_marketable = std::isfinite(order.limit_price) + && (live_long ? bar.open >= order.limit_price + : bar.open <= order.limit_price); + if (stop_gapped == limit_marketable) return false; + if (limit_leg != nullptr) *limit_leg = limit_marketable; int matching_children = 0; for (const PendingOrder& pending : pending_orders_) { @@ -4909,7 +4929,7 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( } } const bool prearmed_market_gap = - prearmed_market_parent_stop_gaps_at_open(order, bar); + prearmed_market_parent_bracket_gaps_at_open(order, bar); if (!prearmed_market_gap && !bar_magnifier_enabled_ && !(calc_on_order_fills_ && coof_scheduler_active_ && order.created_during_coof_recalc)) { @@ -4961,12 +4981,18 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( bool is_limit_fill = false; // A valid child that was armed with its pending MARKET parent and whose - // stop is already breached at the parent's fill open scratches there. - // Route it directly: the generic entry-bar resolver intentionally blocks - // wrong-side levels and remains unchanged for every other provenance. - if (exit_style && prearmed_market_parent_stop_gaps_at_open(order, bar)) { + // stop is already breached — or whose limit is already marketable — at + // the parent's fill open scratches there. Route it directly: the generic + // entry-bar resolver intentionally blocks wrong-side levels and remains + // unchanged for every other provenance. A limit-leg scratch books at the + // open on the unslipped limit-or-better path (TV does not slip limit + // fills); the stop leg keeps its established slipped-stop booking. + bool prearmed_bracket_limit_leg = false; + if (exit_style && prearmed_market_parent_bracket_gaps_at_open( + order, bar, &prearmed_bracket_limit_leg)) { fill_price = bar.open; should_fill = true; + is_limit_fill = prearmed_bracket_limit_leg; } // If every non-trailing priced leg is suppressed on the entry bar, the diff --git a/tests/test_prearmed_market_parent_gap_exit.cpp b/tests/test_prearmed_market_parent_gap_exit.cpp index fcb380b..f2ef724 100644 --- a/tests/test_prearmed_market_parent_gap_exit.cpp +++ b/tests/test_prearmed_market_parent_gap_exit.cpp @@ -8,6 +8,21 @@ * * The six cells mirror the clean-room TradingView probe * order-market-reversal-resting-bracket-gap-01 (A-F). + * + * The LIMIT-leg cells below pin finding 278 seed (b) + * (rhyme17-trendline-and-horizontal-breakout, six tape events): on a + * reversal fill bar TV still honors the STANDING prior-bar strategy.exit + * whose levels were computed from the OLD (reversed-away) position's avg + * price. A limit already marketable at the fill bar's open fills AT THE + * OPEN — a duration-0 PnL-0 trade for the new position — and the re-priced + * bracket issued at that bar's close governs afterwards. Equality with the + * open is marketable (2025-04-07 14:00 UTC: entry and exit both 1549.51). + * + * Scope: these cells exercise exit ORDER lifecycle only (when a standing + * strategy.exit may fill on its parent's fill bar). They do not touch the + * reverted same-bar position_size visibility class, the #146 same-tick + * close+reverse sequencing kernel, or ordinary non-reversal exit re-issues + * (see the OngoingPositionReissue control). */ #include @@ -197,6 +212,267 @@ static void check_partial_qty_does_not_scratch_parent_open(double exit_qty) { CHECK(near(probe.live_qty(), 1.0)); } +// ── LIMIT-leg cells (finding 278 seed (b), rhyme17 stale-exit family) ── + +enum class LimitCell { + FlatLongLimit, // flat parent, TP limit below the fill open + FlatShortLimit, // flat parent, TP limit above the fill open + ReversalLongLimit, // short→long reversal, stale old-avg TP below open + ReversalShortLimit, // long→short reversal, stale old-avg TP above open + ReversalLongLimitEq, // rhyme17 2025-04-07 shape: limit == open exactly + ReversalLongLimitPostOpen, // correctly-sided limit, fills later at level + ReversalShortLimitPostOpen, // correctly-sided limit, fills later at level + ReversalDualMarketable, // stop AND limit marketable: no open scratch +}; + +class PrearmedLimitBracketProbe final : public BacktestEngine { +public: + explicit PrearmedLimitBracketProbe(LimitCell cell) : cell_(cell) { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + process_orders_on_close_ = false; + calc_on_order_fills_ = false; + } + + double live_qty() const { return position_qty_; } + bool is_flat() const { return position_side_ == PositionSide::FLAT; } + + void on_bar(const Bar&) override { + const bool flat_parent = cell_ == LimitCell::FlatLongLimit + || cell_ == LimitCell::FlatShortLimit; + const bool opens_long = cell_ != LimitCell::FlatShortLimit + && cell_ != LimitCell::ReversalShortLimit + && cell_ != LimitCell::ReversalShortLimitPostOpen; + + double limit_px; + double stop_px; + switch (cell_) { + case LimitCell::FlatLongLimit: + case LimitCell::ReversalLongLimit: + // Marketable at the O=100 fill open (open >= limit) with a + // correctly-sided, non-gapped stop sibling. + limit_px = 95.0; + stop_px = 90.0; + break; + case LimitCell::FlatShortLimit: + case LimitCell::ReversalShortLimit: + limit_px = 105.0; + stop_px = 110.0; + break; + case LimitCell::ReversalLongLimitEq: + // Equality is marketable (rhyme17 event 1: open == limit). + limit_px = 100.0; + stop_px = 90.0; + break; + case LimitCell::ReversalLongLimitPostOpen: + limit_px = 110.0; // above open: not marketable at open + stop_px = 80.0; // out of the bar's range: limit leg fills + break; + case LimitCell::ReversalShortLimitPostOpen: + limit_px = 90.0; // below open: not marketable at open + stop_px = 120.0; // out of the bar's range: limit leg fills + break; + case LimitCell::ReversalDualMarketable: + limit_px = 95.0; // marketable at open ... + stop_px = 105.0; // ... and the stop is gapped too + break; + } + + if (flat_parent && bar_index_ == 0) { + strategy_entry("E", opens_long, kNaN, kNaN, 1.0, "flat parent"); + strategy_exit("X", "E", limit_px, stop_px, + kNaN, kNaN, kNaN, 100.0, "prearmed limit"); + return; + } + + if (!flat_parent && bar_index_ == 0) { + strategy_entry("OLD", !opens_long, kNaN, kNaN, 1.0, "seed"); + return; + } + + if (!flat_parent && bar_index_ == 1) { + strategy_entry("E", opens_long, kNaN, kNaN, 1.0, "reverse parent"); + strategy_exit("X", "E", limit_px, stop_px, + kNaN, kNaN, kNaN, 100.0, "stale old-avg bracket"); + } + } + +private: + LimitCell cell_; +}; + +static void check_flat_limit_gap(LimitCell cell, bool is_long) { + PrearmedLimitBracketProbe probe(cell); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 102.0, 98.0, 100.0), + bar(3'000, 100.0, 102.0, 98.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.is_long == is_long); + CHECK(t.entry_bar_index == 1); + CHECK(t.exit_bar_index == 1); + // Fill books at the OPEN (limit-or-better), not at the limit level. + CHECK(near(t.entry_price, 100.0)); + CHECK(near(t.exit_price, 100.0)); + CHECK(near(t.qty, 1.0)); + CHECK(near(t.pnl, 0.0)); + CHECK(t.exit_id == "X"); + CHECK(probe.is_flat()); + CHECK(near(probe.live_qty(), 0.0)); +} + +static void check_reversal_limit(LimitCell cell, bool new_is_long, + bool post_open) { + PrearmedLimitBracketProbe probe(cell); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 101.0, 99.0, 100.0), + // Marketable cells scratch at O=100 (duration-0, PnL-0). The + // post-open controls carry a correctly-sided limit at 110/90, + // touched later by H=110 / L=90 and filled at that level. + bar(3'000, 100.0, 110.0, 90.0, 100.0), + bar(4'000, 100.0, 110.0, 90.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 2); + if (probe.trade_count() != 2) return; + const Trade& old = probe.get_trade(0); + const Trade& fresh = probe.get_trade(1); + CHECK(old.is_long != new_is_long); + CHECK(fresh.is_long == new_is_long); + CHECK(fresh.entry_bar_index == 2); + CHECK(fresh.exit_bar_index == 2); + CHECK(near(fresh.entry_price, 100.0)); + CHECK(near(fresh.exit_price, + post_open ? (new_is_long ? 110.0 : 90.0) : 100.0)); + CHECK(near(fresh.qty, 1.0)); + CHECK(near(fresh.pnl, post_open ? 10.0 : 0.0)); + CHECK(fresh.exit_id == "X"); + CHECK(probe.is_flat()); + CHECK(near(probe.live_qty(), 0.0)); +} + +// Dual-marketable bracket (stop gapped AND limit marketable at the open): +// stays OFF the open-scratch path — no duration-0 trade on the entry bar. +// The wrong-side stop is skipped on the entry bar and the order fires via +// the ordinary resting-order gap on the NEXT bar's open (pre-existing +// behavior, unchanged by the limit-leg extension). +static void check_reversal_dual_marketable_holds_entry_bar() { + PrearmedLimitBracketProbe probe(LimitCell::ReversalDualMarketable); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 101.0, 99.0, 100.0), + bar(3'000, 100.0, 101.0, 99.0, 100.0), + bar(4'000, 100.0, 101.0, 99.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 2); + if (probe.trade_count() != 2) return; + const Trade& fresh = probe.get_trade(1); + CHECK(fresh.entry_bar_index == 2); + CHECK(fresh.exit_bar_index == 3); // NOT the entry bar + CHECK(near(fresh.entry_price, 100.0)); + CHECK(near(fresh.exit_price, 100.0)); +} + +// Ordinary non-reversal exit re-issue control: the position has been open +// since an EARLIER bar when a fresh strategy.exit with a marketable limit is +// issued. The prearmed oracle must not treat it as a parent-fill-bar scratch +// (position_open_bar_ gate): the exit fills on its ordinary next-bar +// resting-order path and the trade keeps its original entry bar. +class OngoingPositionReissue final : public BacktestEngine { +public: + OngoingPositionReissue() { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + } + + double live_qty() const { return position_qty_; } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("E", true, kNaN, kNaN, 1.0, "hold"); + } else if (bar_index_ == 2) { + strategy_exit("X", "E", /*limit=*/99.0, kNaN, + kNaN, kNaN, kNaN, 100.0, "re-issue"); + } + } +}; + +static void check_ongoing_position_reissue_keeps_entry() { + OngoingPositionReissue probe; + std::vector bars = { + bar(1'000, 100.0, 100.5, 99.5, 100.0), + bar(2'000, 100.0, 100.5, 99.5, 100.0), + bar(3'000, 100.0, 100.5, 99.5, 100.0), + bar(4'000, 100.0, 100.5, 99.5, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.entry_bar_index == 1); // original entry preserved + CHECK(t.exit_bar_index == 3); // fills on the re-issue's next bar + CHECK(near(t.entry_price, 100.0)); + CHECK(near(t.exit_price, 100.0)); + CHECK(near(probe.live_qty(), 0.0)); +} + +class PartialFlatLimitBracket final : public BacktestEngine { +public: + explicit PartialFlatLimitBracket(double exit_qty) : exit_qty_(exit_qty) { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 1; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + strategy_entry("E", true, kNaN, kNaN, 1.0); + strategy_exit("X", "E", /*limit=*/95.0, /*stop=*/90.0, + kNaN, kNaN, kNaN, 100.0, "partial limit", exit_qty_); + } + + double live_qty() const { return position_qty_; } + +private: + double exit_qty_; +}; + +static void check_partial_limit_does_not_scratch_parent_open() { + PartialFlatLimitBracket probe(0.5); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 102.0, 98.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 0); + CHECK(near(probe.live_qty(), 1.0)); +} + class MultipleFlatParents final : public BacktestEngine { public: MultipleFlatParents() { @@ -245,6 +521,18 @@ int main() { check_partial_qty_does_not_scratch_parent_open(0.9999999995); check_multiple_market_parents_do_not_share_scratch(); + // LIMIT-leg cells (finding 278 seed (b), rhyme17 stale-exit family). + check_flat_limit_gap(LimitCell::FlatLongLimit, true); + check_flat_limit_gap(LimitCell::FlatShortLimit, false); + check_reversal_limit(LimitCell::ReversalLongLimit, true, false); + check_reversal_limit(LimitCell::ReversalShortLimit, false, false); + check_reversal_limit(LimitCell::ReversalLongLimitEq, true, false); + check_reversal_limit(LimitCell::ReversalLongLimitPostOpen, true, true); + check_reversal_limit(LimitCell::ReversalShortLimitPostOpen, false, true); + check_reversal_dual_marketable_holds_entry_bar(); + check_ongoing_position_reissue_keeps_entry(); + check_partial_limit_does_not_scratch_parent_open(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; }