From 68e0062540e9f444e78b24def30b4e0b30f32226 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 16 Jul 2026 10:48:30 +0800 Subject: [PATCH] Fix relative exits after non-gap limit parent fills --- include/pineforge/engine.hpp | 5 +- src/engine_fills.cpp | 100 ++++++- tests/CMakeLists.txt | 1 + .../test_relative_exit_after_limit_parent.cpp | 281 ++++++++++++++++++ 4 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 tests/test_relative_exit_after_limit_parent.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 79a07ff..2abcef6 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -59,10 +59,11 @@ struct PyramidEntry { // sequence, the exit covers (scratches) the add dur-0. Gated by // entry_bar_index == bar_index_ so prior-bar slices are never covered. bool market_pyramid_add = false; - // Synthetic OHLC-path coordinate where a pure-stop strategy.entry fired. + // Synthetic OHLC-path coordinate where a pure stop/limit strategy.entry + // fired. // A single flat-born, non-trailing from_entry bracket may inspect only the // path suffix at/after this cursor on the entry bar. NaN marks every - // unrouted parent class (market, limit, stop-limit, raw order). + // unrouted parent class (market, stop-limit, raw order). double entry_path_position = std::numeric_limits::quiet_NaN(); }; diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 5268fab..8550776 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -902,9 +902,87 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { live_flat_market_pair_seqs.insert(order.created_seq); } } + // A single relative strategy.exit armed while FLAT has no concrete + // stop/limit until its earlier same-bar from_entry parent fills. It + // otherwise looks like a phase-0 market order and sorts before a non-gap + // LIMIT parent; the flat-position scan skips it, then never revisits it + // after the parent materializes its prices. + // + // Put only that exact child in its parent's existing phase 1. Do not invent + // a global phase 2: that would move the child behind unrelated phase-1 + // parents, and would also wake unsupported multi-child groups after their + // parent. Gap-at-open LIMIT parents already share phase 0 with the child + // and keep their established source ordering. COOF and magnifier own + // separate path schedulers and remain out of scope. + struct RelativeLimitParentFence { + int created_bar; + int64_t created_seq; + }; + std::unordered_map exit_children_by_parent; + std::unordered_map + non_gap_limit_parents; + std::unordered_set relative_limit_child_incarnations; + if (position_side_ == PositionSide::FLAT + && !calc_on_order_fills_ + && !bar_magnifier_enabled_ + && std::isfinite(bar.open)) { + for (const PendingOrder& order : pending_orders_) { + if (order.type == OrderType::EXIT && !order.from_entry.empty()) { + ++exit_children_by_parent[order.from_entry]; + } + } + for (const PendingOrder& order : pending_orders_) { + const bool pure_limit_parent = + order.type == OrderType::ENTRY + && !order.id.empty() + && order.created_position_side == PositionSide::FLAT + && order.created_bar < bar_index_ + && !order.created_during_coof_recalc + && std::isfinite(order.limit_price) + && std::isnan(order.stop_price) + && std::isnan(order.trail_points) + && std::isnan(order.trail_price) + && std::isnan(order.trail_offset); + const bool fills_at_open = pure_limit_parent + && (order.is_long ? bar.open <= order.limit_price + : bar.open >= order.limit_price); + if (pure_limit_parent && !fills_at_open) { + non_gap_limit_parents.emplace( + order.id, + RelativeLimitParentFence{ + order.created_bar, order.created_seq}); + } + } + for (const PendingOrder& order : pending_orders_) { + auto parent = non_gap_limit_parents.find(order.from_entry); + if (parent == non_gap_limit_parents.end()) continue; + const bool exact_relative_child = + order.type == OrderType::EXIT + && !order.created_while_in_position + && order.created_position_side == PositionSide::FLAT + && !order.created_during_coof_recalc + && exit_children_by_parent[order.from_entry] == 1 + && order.created_bar == parent->second.created_bar + && parent->second.created_seq < order.created_seq + && std::isnan(order.limit_price) + && std::isnan(order.stop_price) + && std::isnan(order.trail_points) + && std::isnan(order.trail_price) + && std::isnan(order.trail_offset) + && (std::isfinite(order.profit_ticks) + || std::isfinite(order.loss_ticks)); + if (exact_relative_child) { + relative_limit_child_incarnations.insert(order.incarnation); + } + } + } std::stable_sort(pending_orders_.begin(), pending_orders_.end(), [&](const PendingOrder& a, const PendingOrder& b) { auto fill_phase = [&](const PendingOrder& o) { + if (relative_limit_child_incarnations.count(o.incarnation) != 0) { + return 1; + } + bool exit_style = order_is_exit_style(o, position_side_); const bool suppress_entry_bar_leg = exit_style && position_open_bar_ == bar_index_; @@ -2489,8 +2567,8 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri pyramid_entries_.back().price); // Keep the bracket-activation cursor separate from the booked // fill price used by excursion accounting. Stop fills can be - // rounded or slipped; the child becomes live at the actual, - // direction-aware parent trigger crossing. + // rounded or slipped; limit fills can improve at the open. The + // child becomes live at the actual parent trigger crossing. if (!std::isnan(order.stop_price) && std::isnan(order.limit_price)) { double entry_path_position = 0.0; @@ -2500,6 +2578,18 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri pyramid_entries_.back().entry_path_position = entry_path_position; } + } else if (std::isnan(order.stop_price) + && !std::isnan(order.limit_price)) { + double entry_path_position = 0.0; + const bool fills_at_open = order.is_long + ? bar.open <= order.limit_price + : bar.open >= order.limit_price; + if (fills_at_open + || internal::first_touch_position( + bar, order.limit_price, &entry_path_position)) { + pyramid_entries_.back().entry_path_position = + entry_path_position; + } } } } @@ -3223,9 +3313,9 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( } } else if (!should_fill && exit_style && (has_stop || has_limit || has_trail)) { double path_start_position = 0.0; - // Ordinary historical processing scans the retained pure-stop parent - // entry and its from_entry bracket in one pass. Once the parent fills, - // the child may inspect only the remaining OHLC path. COOF already + // Ordinary historical processing scans the retained pure stop/LIMIT + // parent entry and its from_entry bracket in one pass. Once the parent + // fills, the child may inspect only the remaining OHLC path. COOF already // supplies a monotonic segment cursor, and magnifier has its own tick // path, so neither is routed through this full-bar coordinate. Keep // multi-order exit groups on the existing path until their sibling diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c4cebde..33e9683 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -101,6 +101,7 @@ set(TEST_SOURCES test_pooc_position_visibility test_prearmed_exit_path_cursor test_prearmed_market_parent_gap_exit + test_relative_exit_after_limit_parent ) find_package(Threads REQUIRED) diff --git a/tests/test_relative_exit_after_limit_parent.cpp b/tests/test_relative_exit_after_limit_parent.cpp new file mode 100644 index 0000000..e04ca6b --- /dev/null +++ b/tests/test_relative_exit_after_limit_parent.cpp @@ -0,0 +1,281 @@ +/* + * A relative strategy.exit can be armed while flat beside its pending LIMIT + * from_entry parent. If the parent fills away from the next bar's open, the + * child becomes live at that fill coordinate: later path touches may fill it, + * while target touches that happened before the parent must not be replayed. + * + * These direction-symmetric cells cover the lifecycle that the older market- + * parent relative-exit test cannot expose. A MARKET parent and its unresolved + * child share the open phase; a non-gap LIMIT parent does not. + */ + +#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-9) { + return std::fabs(a - b) <= tol; +} + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +enum class Cell { + LongPostEntryStop, + ShortPostEntryStop, + LongPreEntryTarget, + ShortPreEntryTarget, +}; + +class RelativeLimitBracketProbe final : public BacktestEngine { +public: + explicit RelativeLimitBracketProbe(Cell cell) : cell_(cell) { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + syminfo_mintick_ = 1.0; + pyramiding_ = 1; + process_orders_on_close_ = false; + calc_on_order_fills_ = false; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + + const bool is_long = cell_ == Cell::LongPostEntryStop + || cell_ == Cell::LongPreEntryTarget; + const bool target_only = cell_ == Cell::LongPreEntryTarget + || cell_ == Cell::ShortPreEntryTarget; + strategy_entry("E", is_long, /*limit=*/100.0, /*stop=*/kNaN, + /*qty=*/1.0, "non-gap limit parent"); + strategy_exit("X", "E", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + target_only ? "pre-entry target" : "post-entry stop", + /*qty=*/kNaN, /*oca_name=*/"", + /*profit_ticks=*/target_only ? 5.0 : kNaN, + /*loss_ticks=*/target_only ? kNaN : 5.0); + } + +private: + Cell cell_; +}; + +class MultiChildFenceProbe final : public BacktestEngine { +public: + MultiChildFenceProbe() { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + syminfo_mintick_ = 1.0; + pyramiding_ = 1; + process_orders_on_close_ = false; + calc_on_order_fills_ = false; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + + strategy_entry("E", true, /*limit=*/100.0, /*stop=*/kNaN, + /*qty=*/1.0, "limit parent"); + strategy_exit("T", "E", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + "target child", /*qty=*/1.0, /*oca_name=*/"", + /*profit_ticks=*/5.0, /*loss_ticks=*/kNaN); + strategy_exit("S", "E", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + "far stop child", /*qty=*/1.0, /*oca_name=*/"", + /*profit_ticks=*/kNaN, /*loss_ticks=*/50.0); + } +}; + +class MultiParentFenceProbe final : public BacktestEngine { +public: + MultiParentFenceProbe() { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + syminfo_mintick_ = 1.0; + pyramiding_ = 2; + process_orders_on_close_ = false; + calc_on_order_fills_ = false; + } + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + + strategy_entry("E1", true, /*limit=*/100.0, /*stop=*/kNaN, + /*qty=*/1.0, "first parent"); + strategy_exit("X1", "E1", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + "first stop", /*qty=*/1.0, /*oca_name=*/"", + /*profit_ticks=*/kNaN, /*loss_ticks=*/2.0); + strategy_entry("E2", true, /*limit=*/95.0, /*stop=*/kNaN, + /*qty=*/1.0, "second parent"); + strategy_exit("X2", "E2", /*limit=*/kNaN, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + "second target", /*qty=*/1.0, /*oca_name=*/"", + /*profit_ticks=*/5.0, /*loss_ticks=*/kNaN); + } +}; + +static Bar bar(int64_t ts, double o, double h, double l, double c) { + return {o, h, l, c, 1'000.0, ts}; +} + +static void check_cell(Cell cell, bool is_long, bool target_only) { + RelativeLimitBracketProbe probe(cell); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + is_long + // O-H-L-C: target 105 is pre-parent; limit 100 then stop 95. + ? bar(2'000, 105.0, 110.0, 90.0, 95.0) + // O-L-H-C: target 95 is pre-parent; limit 100 then stop 105. + : bar(2'000, 95.0, 110.0, 90.0, 105.0), + is_long + ? bar(3'000, 95.0, 106.0, 94.0, 105.0) + : bar(3'000, 105.0, 106.0, 94.0, 95.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + const char* label = is_long + ? (target_only ? "long-pre-target" : "long-post-stop") + : (target_only ? "short-pre-target" : "short-post-stop"); + std::printf(" %s: trades=%d", label, probe.trade_count()); + if (probe.trade_count() > 0) { + const Trade& observed = probe.get_trade(0); + std::printf(" entry=%d@%.2f exit=%d@%.2f", + observed.entry_bar_index, observed.entry_price, + observed.exit_bar_index, observed.exit_price); + } + std::printf("\n"); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + + const Trade& trade = probe.get_trade(0); + CHECK(trade.is_long == is_long); + CHECK(trade.entry_bar_index == 1); + CHECK(trade.exit_bar_index == (target_only ? 2 : 1)); + CHECK(near(trade.entry_price, 100.0)); + CHECK(near(trade.exit_price, is_long + ? (target_only ? 105.0 : 95.0) + : (target_only ? 95.0 : 105.0))); + CHECK(near(trade.pnl, target_only ? 5.0 : -5.0)); + CHECK(trade.exit_id == "X"); +} + +static void check_multi_child_fence() { + MultiChildFenceProbe probe; + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + // O-H-L-C: target 105 is elapsed before the parent reaches 100. + bar(2'000, 105.0, 110.0, 90.0, 95.0), + bar(3'000, 100.0, 106.0, 99.0, 105.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + std::printf(" multi-child fence: trades=%d", probe.trade_count()); + if (probe.trade_count() > 0) { + const Trade& observed = probe.get_trade(0); + std::printf(" entry=%d@%.2f exit=%d@%.2f id=%s", + observed.entry_bar_index, observed.entry_price, + observed.exit_bar_index, observed.exit_price, + observed.exit_id.c_str()); + } + std::printf("\n"); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + + const Trade& trade = probe.get_trade(0); + CHECK(trade.is_long); + CHECK(trade.entry_bar_index == 1); + CHECK(trade.exit_bar_index == 2); + CHECK(near(trade.entry_price, 100.0)); + CHECK(near(trade.exit_price, 105.0)); + CHECK(trade.exit_id == "T"); +} + +static void check_multi_parent_fence() { + MultiParentFenceProbe probe; + std::vector bars = { + bar(1'000, 105.0, 106.0, 104.0, 105.0), + // O-H-L-C: E1 100 -> X1 98 -> E2 95. The second parent must not + // leapfrog X1 merely because both parents were initially phase 1. + bar(2'000, 105.0, 110.0, 90.0, 95.0), + bar(3'000, 95.0, 105.0, 94.0, 104.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + std::printf(" multi-parent fence: trades=%d\n", probe.trade_count()); + for (int i = 0; i < probe.trade_count(); ++i) { + const Trade& observed = probe.get_trade(i); + std::printf(" %s entry=%d@%.2f exit=%d@%.2f id=%s\n", + observed.entry_id.c_str(), observed.entry_bar_index, + observed.entry_price, observed.exit_bar_index, + observed.exit_price, observed.exit_id.c_str()); + } + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 2); + if (probe.trade_count() != 2) return; + + const Trade& first = probe.get_trade(0); + const Trade& second = probe.get_trade(1); + CHECK(first.entry_id == "E1"); + CHECK(first.entry_bar_index == 1); + CHECK(first.exit_bar_index == 1); + CHECK(near(first.entry_price, 100.0)); + CHECK(near(first.exit_price, 98.0)); + CHECK(first.exit_id == "X1"); + CHECK(second.entry_id == "E2"); + CHECK(second.entry_bar_index == 1); + CHECK(second.exit_bar_index == 2); + CHECK(near(second.entry_price, 95.0)); + CHECK(near(second.exit_price, 100.0)); + CHECK(second.exit_id == "X2"); +} + +int main() { + std::printf("relative exit after non-gap LIMIT parent\n"); + check_cell(Cell::LongPostEntryStop, true, false); + check_cell(Cell::ShortPostEntryStop, false, false); + check_cell(Cell::LongPreEntryTarget, true, true); + check_cell(Cell::ShortPreEntryTarget, false, true); + check_multi_child_fence(); + check_multi_parent_fence(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}