diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 2abcef6..2fb7079 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -2151,6 +2151,8 @@ class BacktestEngine { uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long, std::vector& filled_indices); + bool stop_entry_margin_admission_declines( + const PendingOrder& order, double fill_price, const Bar& bar) const; // design-declined-reversal-close-leg: called at the KI-54 reversal-decline // site with the just-declined MARKET reversal entry. Flags every pending // FULL close that was co-queued after it on the same bar against the held diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 8550776..3e81d4f 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -44,9 +44,70 @@ bool preserves_same_id_stop_across_deferred_close_all( && !order.stop_limit_activated; } +// TradingView continues along the historical OHLC path after the first +// member of this exact dual-stop book is declined by margin admission. Keep +// the exception on the independently-proven shape: two same-signal, +// true-flat, unlinked strategy.entry pure STOPs and no competing entry-like +// orders. EXIT orders are harmless while flat and retain ordinary cleanup. +bool is_true_flat_unlinked_stop_pair( + const std::vector& orders, + DualEntryStopPathWinner winner) { + if (winner != DualEntryStopPathWinner::LongFirst + && winner != DualEntryStopPathWinner::ShortFirst) { + return false; + } + + int pure_stop_entries = 0; + int source_bar = 0; + bool have_source_bar = false; + for (const PendingOrder& order : orders) { + const bool entry_like = order.type == OrderType::ENTRY + || order.type == OrderType::MARKET + || order.type == OrderType::RAW_ORDER; + if (!entry_like) continue; + + const bool pure_stop = order.type == OrderType::ENTRY + && std::isfinite(order.stop_price) + && std::isnan(order.limit_price) + && std::isnan(order.trail_points) + && std::isnan(order.trail_price) + && std::isnan(order.trail_offset) + && !order.stop_limit_activated; + if (!pure_stop + || order.created_position_side != PositionSide::FLAT + || order.created_after_position_close_in_bar + || !order.oca_name.empty() + || order.oca_type != 0) { + return false; + } + if (!have_source_bar) { + source_bar = order.created_bar; + have_source_bar = true; + } else if (order.created_bar != source_bar) { + return false; + } + ++pure_stop_entries; + } + return pure_stop_entries == 2; +} + } // namespace +bool internal::dual_stop_margin_decline_can_continue_path( + const std::vector& orders, + DualEntryStopPathWinner winner, + bool process_orders_on_close, + bool calc_on_order_fills, + bool bar_magnifier) { + return winner != DualEntryStopPathWinner::None + && !process_orders_on_close + && !calc_on_order_fills + && !bar_magnifier + && is_true_flat_unlinked_stop_pair(orders, winner); +} + + // strategy_entry / strategy_close / strategy_close_all / strategy_exit // moved to engine_strategy_commands.cpp. void BacktestEngine::process_pending_orders(const Bar& bar) { @@ -79,6 +140,10 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { dual_entry_path_ = dual_entry_stop_path_winner( bar, pending_orders_, bar_index_); } + const bool continue_after_stop_margin_decline_scope = + dual_stop_margin_decline_can_continue_path( + pending_orders_, dual_entry_path_, process_orders_on_close_, + calc_on_order_fills_, bar_magnifier_enabled_); for (int opposing_pass = 0; opposing_pass < 2; ++opposing_pass) { // Pass 1 only re-evaluates orders pass 0 deferred into the skip set; @@ -114,12 +179,27 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { continue; } + const bool path_winner_stop_margin_decline = + continue_after_stop_margin_decline_scope + && ((dual_entry_path_ == DualEntryStopPathWinner::LongFirst + && order.is_long) + || (dual_entry_path_ == DualEntryStopPathWinner::ShortFirst + && !order.is_long)) + && check_risk_allow_entry(order.is_long) + && stop_entry_margin_admission_declines( + order, fill.fill_price, bar); apply_filled_order_to_state( order, i, fill.fill_price, fill.is_limit_fill, bar, trail_best_path_state, exit_closed_from_bar, exit_closed_from_incarnation, exit_closed_was_long, filled_indices); + if (path_winner_stop_margin_decline) { + // The path winner never became a broker fill. Releasing only the + // path-winner fence lets the already-deferred, path-later stop + // face every ordinary eligibility and admission rule in turn. + dual_entry_path_ = DualEntryStopPathWinner::None; + } materialize_relative_exit_prices_for_live_position(); } compact_filled_pending_orders( @@ -1327,6 +1407,27 @@ void BacktestEngine::compact_filled_pending_orders( } +bool BacktestEngine::stop_entry_margin_admission_declines( + const PendingOrder& order, double fill_price, const Bar& bar) const { + if (order.type != OrderType::ENTRY + || std::isnan(order.stop_price) + || !std::isnan(order.limit_price)) { + return false; + } + const double margin_pct = order.is_long ? margin_long_ : margin_short_; + if (!(margin_pct > 0.0) || !std::isfinite(bar.open) || bar.open <= 0.0) { + return false; + } + const double fill_qty = + std::abs(calc_qty_for_type(fill_price, order.qty, order.qty_type)); + const double required = fill_qty * bar.open * syminfo_.pointvalue + * account_currency_fx_ * (margin_pct / 100.0); + const double available = current_equity(); + const double eps = std::max(1e-9, std::abs(available) * 1e-12); + return fill_qty > 0.0 && required > available + eps; +} + + // Apply a successfully matched fill to engine state. Dispatches by // order.type to the appropriate execute_* method, updates trailing-stop // best price, handles risk gating + intraday-fill caps + OCA group @@ -1406,22 +1507,9 @@ void BacktestEngine::apply_filled_order_to_state( // byte-identical when margin_pct==0). The qty is exactly the fill kernel's // (calc_qty_for_type at the fill price) so the gate cannot diverge from the // fill it guards. - if (order.type == OrderType::ENTRY - && !std::isnan(order.stop_price) - && std::isnan(order.limit_price)) { - const double margin_pct = order.is_long ? margin_long_ : margin_short_; - if (margin_pct > 0.0 && std::isfinite(bar.open) && bar.open > 0.0) { - const double fill_qty = - std::abs(calc_qty_for_type(fill_price, order.qty, order.qty_type)); - const double required = fill_qty * bar.open * syminfo_.pointvalue - * account_currency_fx_ * (margin_pct / 100.0); - const double available = current_equity(); - const double eps = std::max(1e-9, std::abs(available) * 1e-12); - if (fill_qty > 0.0 && required > available + eps) { - decline_and_cancel(); - return; - } - } + if (stop_entry_margin_admission_declines(order, fill_price, bar)) { + decline_and_cancel(); + return; } // A fixed-default MARKET entry can change role between placement and fill: diff --git a/src/engine_internal.hpp b/src/engine_internal.hpp index 8efd62a..17c5ea4 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -146,6 +146,17 @@ DualEntryStopPathWinner dual_entry_stop_path_winner(const Bar& bar, int current_bar_index = -1); +// Exact scope for continuing the historical path after a dual-stop winner is +// declined by fill-time margin admission. Kept runtime-private so focused +// tests can pin the POOC/COOF/magnifier and order-book fences directly. +bool dual_stop_margin_decline_can_continue_path( + const std::vector& orders, + DualEntryStopPathWinner winner, + bool process_orders_on_close, + bool calc_on_order_fills, + bool bar_magnifier); + + // For OCA exit siblings (e.g., separate TP and SL strategy.order calls), // compute first-touch position on OHLC path for a single-priced order. bool exit_order_touch_position(const Bar& bar, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 33e9683..f2e445f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -72,6 +72,7 @@ set(TEST_SOURCES test_margin_admission_gate test_short_reversal_emission test_margin_stop_admission + test_stop_decline_continue_path test_frozen_flat_gap_reject test_explicit_qty_fill_admission test_limit_fill_slippage diff --git a/tests/test_stop_decline_continue_path.cpp b/tests/test_stop_decline_continue_path.cpp new file mode 100644 index 0000000..8ca8fcd --- /dev/null +++ b/tests/test_stop_decline_continue_path.cpp @@ -0,0 +1,290 @@ +/* + * When the path-first member of a same-signal, true-flat, unlinked opposing + * pure-STOP pair is cancelled specifically by stop-margin admission, the + * ordinary historical path scanner must consider the later member. All other + * rejection causes and order-book/scheduler shapes retain their old paths. + */ + +#include +#include +#include +#include + +#include +#include +#include + +#include "../src/engine_internal.hpp" + +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) + +namespace { + +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +Bar bar(int64_t timestamp, double open, double high, double low, + double close) { + return {open, high, low, close, 1.0, timestamp}; +} + +class DualStopProbe : public BacktestEngine { +public: + DualStopProbe() { + initial_capital_ = 10'000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + margin_long_ = 100.0; + margin_short_ = 100.0; + qty_step_ = 0.0; + set_margin_call_enabled(false); + } + + double long_stop = 110.0; + double short_stop = 90.0; + bool use_oca = false; + bool mark_as_after_close = false; + bool split_signal_bars = false; + enum class MixedOrder { None, Market, Limit, Raw }; + MixedOrder mixed_order = MixedOrder::None; + + void on_bar(const Bar&) override { + if (bar_index_ != 0) return; + const std::string oca = use_oca ? "PAIR" : ""; + const int oca_type = use_oca ? 1 : 0; + strategy_entry("L", true, kNaN, long_stop, kNaN, "", oca, + oca_type); + strategy_entry("S", false, kNaN, short_stop, kNaN, "", oca, + oca_type); + if (split_signal_bars) { + pending_orders_.back().created_bar -= 1; + } + if (mark_as_after_close) { + for (PendingOrder& order : pending_orders_) { + if (order.type == OrderType::ENTRY) { + order.created_after_position_close_in_bar = true; + } + } + } + if (mixed_order == MixedOrder::Market) { + strategy_entry("M", true, kNaN, kNaN, 1.0); + } else if (mixed_order == MixedOrder::Limit) { + strategy_entry("X", true, 95.0, kNaN, 1.0); + } else if (mixed_order == MixedOrder::Raw) { + strategy_order("R", true, 1.0, 95.0, kNaN); + } + } + + void long_only() { risk_direction_ = RiskDirection::LONG_ONLY; } + void enable_pooc() { process_orders_on_close_ = true; } + void enable_coof() { calc_on_order_fills_ = true; } + bool continuation_scope(bool magnifier = false) { + bar_index_ = 0; + on_bar(Bar{}); + const bool scoped = internal::dual_stop_margin_decline_can_continue_path( + pending_orders_, internal::DualEntryStopPathWinner::ShortFirst, + process_orders_on_close_, calc_on_order_fills_, magnifier); + pending_orders_.clear(); + bar_index_ = -1; + return scoped; + } + PositionSide side() const { return position_side_; } + double qty() const { return position_qty_; } + double entry_price() const { return position_entry_price_; } +}; + +// O=100 is tied between H=111 and L=89, so the synthesized path is +// O->L->H->C. The short stop at 90 is first. Its all-in qty is 10000/90, +// but admission costs that qty at open 100, so it declines. The later long +// stop is affordable because its qty is 10000/110. +Bar low_first_dual_touch() { + return bar(2'000, 100.0, 111.0, 89.0, 100.0); +} + +void run_pair(DualStopProbe& probe, bool magnifier = false) { + Bar bars[] = { + bar(1'000, 100.0, 100.0, 100.0, 100.0), + low_first_dual_touch(), + }; + if (magnifier) { + probe.run(bars, 2, "15", "15", true, 4, + MagnifierDistribution::ENDPOINTS); + } else { + probe.run(bars, 2); + } +} + +void test_margin_decline_continues_to_affordable_later_stop() { + std::printf("margin decline continues to the affordable later stop\n"); + DualStopProbe scope_probe; + CHECK(scope_probe.continuation_scope()); + DualStopProbe probe; + run_pair(probe); + CHECK(probe.side() == PositionSide::LONG); + CHECK(std::fabs(probe.entry_price() - 110.0) < 1e-9); + CHECK(std::fabs(probe.qty() - (10'000.0 / 110.0)) < 1e-9); + CHECK(probe.trade_count() == 0); +} + +void test_accepted_first_stop_preserves_existing_second_touch_result() { + std::printf("accepted first stop preserves the existing dual-touch result\n"); + DualStopProbe probe; + probe.short_stop = 100.0; // marketable at open; qty*open == equity + run_pair(probe); + // The accepted short opens first; the existing path logic then applies the + // later long touch, closing most of it. The margin-decline continuation + // must not alter this pre-existing residual/trade shape. + CHECK(probe.side() == PositionSide::SHORT); + CHECK(std::fabs(probe.qty() - (100.0 - 10'000.0 / 110.0)) < 1e-9); + CHECK(std::fabs(probe.entry_price() - 100.0) < 1e-9); + CHECK(probe.trade_count() == 1); +} + +void test_non_margin_rejection_does_not_continue() { + std::printf("risk rejection does not release the later stop\n"); + DualStopProbe scope_probe; + scope_probe.long_only(); + CHECK(scope_probe.continuation_scope()); + DualStopProbe probe; + probe.long_only(); + run_pair(probe); + CHECK(probe.side() == PositionSide::FLAT); + CHECK(probe.trade_count() == 0); +} + +void test_oca_pair_is_out_of_scope() { + std::printf("OCA pair retains ordinary cancellation semantics\n"); + DualStopProbe scope_probe; + scope_probe.use_oca = true; + CHECK(!scope_probe.continuation_scope()); + DualStopProbe probe; + probe.use_oca = true; + run_pair(probe); + CHECK(probe.side() == PositionSide::FLAT); + CHECK(probe.trade_count() == 0); +} + +void test_pooc_is_out_of_scope() { + std::printf("POOC path remains unchanged\n"); + DualStopProbe scope_probe; + scope_probe.enable_pooc(); + CHECK(!scope_probe.continuation_scope()); + DualStopProbe probe; + probe.enable_pooc(); + run_pair(probe); + CHECK(probe.side() == PositionSide::FLAT); + CHECK(probe.trade_count() == 0); +} + +void test_coof_is_out_of_scope() { + std::printf("COOF path remains unchanged\n"); + DualStopProbe scope_probe; + scope_probe.enable_coof(); + CHECK(!scope_probe.continuation_scope()); + DualStopProbe probe; + probe.enable_coof(); + run_pair(probe); + CHECK(probe.side() == PositionSide::LONG); + CHECK(std::fabs(probe.qty() - (10'000.0 / 110.0)) < 1e-9); + CHECK(std::fabs(probe.entry_price() - 110.0) < 1e-9); + CHECK(probe.trade_count() == 0); +} + +void test_mixed_order_books_are_out_of_scope() { + std::printf("market/limit/raw books remain unchanged\n"); + const DualStopProbe::MixedOrder kinds[] = { + DualStopProbe::MixedOrder::Market, + DualStopProbe::MixedOrder::Limit, + DualStopProbe::MixedOrder::Raw, + }; + for (DualStopProbe::MixedOrder kind : kinds) { + DualStopProbe scope_probe; + scope_probe.mixed_order = kind; + CHECK(!scope_probe.continuation_scope()); + DualStopProbe probe; + probe.mixed_order = kind; + run_pair(probe); + CHECK(probe.side() == PositionSide::LONG); + if (kind == DualStopProbe::MixedOrder::Market) { + const double stop_qty = 10'000.0 / 110.0; + const double expected_qty = 1.0 + stop_qty; + const double expected_price = + (100.0 + stop_qty * 110.0) / expected_qty; + CHECK(std::fabs(probe.qty() - expected_qty) < 1e-9); + CHECK(std::fabs(probe.entry_price() - expected_price) < 1e-9); + } else { + CHECK(std::fabs(probe.qty() - 1.0) < 1e-9); + CHECK(std::fabs(probe.entry_price() - 95.0) < 1e-9); + } + CHECK(probe.trade_count() == 0); + } +} + +void test_non_true_flat_pair_is_out_of_scope() { + std::printf("post-close flat provenance does not release the later stop\n"); + DualStopProbe scope_probe; + scope_probe.mark_as_after_close = true; + CHECK(!scope_probe.continuation_scope()); + DualStopProbe probe; + probe.mark_as_after_close = true; + run_pair(probe); + CHECK(probe.side() == PositionSide::FLAT); + CHECK(probe.trade_count() == 0); +} + +void test_different_signal_bars_are_out_of_scope() { + std::printf("different-signal stop pair does not release the later stop\n"); + DualStopProbe scope_probe; + scope_probe.split_signal_bars = true; + CHECK(!scope_probe.continuation_scope()); + DualStopProbe probe; + probe.split_signal_bars = true; + run_pair(probe); + CHECK(probe.side() == PositionSide::FLAT); + CHECK(probe.trade_count() == 0); +} + +void test_magnifier_is_out_of_scope() { + std::printf("magnifier path remains unchanged\n"); + DualStopProbe scope_probe; + CHECK(!scope_probe.continuation_scope(true)); + DualStopProbe probe; + run_pair(probe, true); + CHECK(probe.side() == PositionSide::LONG); + CHECK(std::fabs(probe.qty() - (10'000.0 / 110.0)) < 1e-9); + CHECK(std::fabs(probe.entry_price() - 110.0) < 1e-9); + CHECK(probe.trade_count() == 0); +} + +} // namespace + +int main() { + test_margin_decline_continues_to_affordable_later_stop(); + test_accepted_first_stop_preserves_existing_second_touch_result(); + test_non_margin_rejection_does_not_continue(); + test_oca_pair_is_out_of_scope(); + test_pooc_is_out_of_scope(); + test_coof_is_out_of_scope(); + test_mixed_order_books_are_out_of_scope(); + test_non_true_flat_pair_is_out_of_scope(); + test_different_signal_bars_are_out_of_scope(); + test_magnifier_is_out_of_scope(); + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}