diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 26d29a0..9954766 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1843,6 +1843,9 @@ class BacktestEngine { // adverse leg before one script recalculation. Broker fills remain // individually counted by fill_events and broker_fill_event_seq_. bool grouped_stop_recalc = false; + // Physical lot opened by this actual fresh COOF MARKET fill. Only + // its first callback may use this provenance; direct later fills do not. + uint64_t market_entry_incarnation = 0; }; CoofFillResult process_next_pending_order(const Bar& bar, bool allow_market_orders, @@ -2893,6 +2896,11 @@ class BacktestEngine { // lets strategy.exit apply the one pinned exception: a marketable LIMIT may // resume at W1, while marketable STOP suppression remains whole-entry-bar. bool coof_recalc_after_first_open_fill_ = false; + // Round15: identify the MARKET opening whose first callback is active. + // A direct close/partial/reentry in that body changes the serial and must + // not inherit the original fill's permission to arm a recrossing limit. + uint64_t coof_market_entry_recalc_incarnation_ = 0; + uint64_t coof_market_entry_recalc_fill_seq_ = 0; // KI-67: true only during a point-bar evaluation that sits AT an extreme // waypoint (W1 or W2) of the historical 4-tick path. Cascade orders born // this bar may fill only while this holds; on segments, at O, at C, and on @@ -4064,7 +4072,8 @@ class BacktestEngine { uint64_t triggering_events, uint64_t max_events, uint64_t events_already, - bool grouped_stop_recalc = false); + bool grouped_stop_recalc = false, + uint64_t market_entry_incarnation = 0); void run_simple_bar_loop(const Bar* input_bars, int n_input); void run_aggregation_bar_loop(const Bar* input_bars, int n_input, bool bar_magnifier, int expected_script_bars); diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 442f81d..341ebdc 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -622,6 +622,14 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( const PositionSide side_before_fill = position_side_; const uint64_t events_before = broker_fill_event_seq_; const double realized_before_fill = net_profit_sum_; + // Capture before the fill kernel: other order kinds may erase OCA + // siblings, invalidating references into the pending-order vector. + const bool fresh_coof_market_entry = + order.type == OrderType::MARKET + && order.created_during_coof_recalc + && order.created_bar == bar_index_ + && side_before_fill == PositionSide::FLAT; + const uint64_t opening_incarnation = order.incarnation; apply_filled_order_to_state( order, candidate.order_index, candidate.fill.fill_price, candidate.fill.is_limit_fill, bar, @@ -649,6 +657,11 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( result.fill_events = grouped_fill_events; result.chart_waypoint_price = candidate.chart_waypoint_price; result.grouped_stop_recalc = group_resting_stops && grouped_fills > 1; + if (fresh_coof_market_entry && produced == 1 + && position_side_ == PositionSide::LONG + && pyramid_entries_.size() == 1 + && pyramid_entries_.front().entry_incarnation == opening_incarnation) + result.market_entry_incarnation = opening_incarnation; // No callbacks, new orders or OCA erasures can occur in the // proven group. Keep indices stable until its last existing // candidate has passed through the ordinary fill kernel. diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 7ed6c19..01a3eeb 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -316,6 +316,8 @@ uint64_t BacktestEngine::execute_coof_script_body( coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; coof_recalc_after_first_open_fill_ = false; + coof_market_entry_recalc_incarnation_ = 0; + coof_market_entry_recalc_fill_seq_ = 0; coof_direct_fill_events_remaining_ = 0; return broker_fill_event_seq_ - before; } @@ -329,7 +331,8 @@ uint64_t BacktestEngine::run_coof_recalc_chain( uint64_t triggering_events, uint64_t max_events, uint64_t events_already, - bool grouped_stop_recalc) { + bool grouped_stop_recalc, + uint64_t market_entry_incarnation) { uint64_t total_events = triggering_events; uint64_t pending_recalcs = grouped_stop_recalc ? 1 : triggering_events; uint64_t handled = 0; @@ -346,6 +349,9 @@ uint64_t BacktestEngine::run_coof_recalc_chain( recalc_at_bar_open && events_already == 0 && handled == 1; coof_recalc_after_first_open_fill_ = recalc_at_bar_open && !first_open_fill_recalc; + coof_market_entry_recalc_incarnation_ = + handled == 1 ? market_entry_incarnation : 0; + coof_market_entry_recalc_fill_seq_ = broker_fill_event_seq_; const uint64_t direct = execute_coof_script_body( script_bar, broker_cursor_price, cursor_is_bar_point, /*is_fill_recalc=*/true, @@ -409,6 +415,8 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_cascade_recalc_leg_ = -1; coof_cascade_force_wp_gap_ = false; coof_recalc_after_first_open_fill_ = false; + coof_market_entry_recalc_incarnation_ = 0; + coof_market_entry_recalc_fill_seq_ = 0; double path[4]; fill_bar_path_points(script_bar, path); @@ -439,7 +447,7 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { script_bar, fill.fill_price, /*cursor_is_bar_point=*/false, cursor_is_close, filled_at_bar_open_point, fill.fill_events, kNoFillEventBudget, fill_events, - fill.grouped_stop_recalc); + fill.grouped_stop_recalc, fill.market_entry_incarnation); // The carried order's open fill triggers one execution at O, and the // order born in that first execution may also fill at O. Every later // fill—including the first fill when it occurs inside a path segment— @@ -608,6 +616,8 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; coof_recalc_after_first_open_fill_ = false; + coof_market_entry_recalc_incarnation_ = 0; + coof_market_entry_recalc_fill_seq_ = 0; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; @@ -712,6 +722,8 @@ void BacktestEngine::reset_run_state() { coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; coof_recalc_after_first_open_fill_ = false; + coof_market_entry_recalc_incarnation_ = 0; + coof_market_entry_recalc_fill_seq_ = 0; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; @@ -1120,6 +1132,8 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_recalc_after_first_open_fill_ = false; + coof_market_entry_recalc_incarnation_ = 0; + coof_market_entry_recalc_fill_seq_ = 0; double cursor = ticks.front().price; bool cursor_is_bar_point = true; // finding-446, see the simple loop @@ -1257,6 +1271,8 @@ void BacktestEngine::run_magnified_bar_calc_on_order_fills( coof_fill_recalc_active_ = false; coof_recalc_at_bar_open_ = false; coof_recalc_after_first_open_fill_ = false; + coof_market_entry_recalc_incarnation_ = 0; + coof_market_entry_recalc_fill_seq_ = 0; coof_cursor_is_bar_close_ = false; coof_evaluating_path_segment_ = false; coof_at_extreme_waypoint_ = false; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 69b3bb8..c662664 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -2234,11 +2234,46 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro later_same_open_marketable_limit = later_same_open_priced_exit_on_entry_bar && limit_marketable_at_coof_cursor; + // Round15 F/EUR JOAT pins: a fresh MARKET long at W1=H arms a + // marketable full limit, waits through H->L, and takes its later L->C + // recross at the exact level. It neither fills at placement nor gets + // a new waypoint-gap permission. Keep KI-67's existing leg gate. + // This first patch covers only a limit strictly inside H/L with no + // reachable stop competitor and the actual opening's first callback. + const bool first_high_market_limit_recross = + !bar_magnifier_enabled_ && !process_orders_on_close_ + && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE + && order.coof_born_mid_bar && !coof_hist_is_segment_ + && coof_at_extreme_waypoint_ && coof_hist_path_index_ == 1 + && coof_cascade_recalc_leg_ == 1 + && coof_market_entry_recalc_incarnation_ != 0 + && coof_market_entry_recalc_fill_seq_ == broker_fill_event_seq_ + && position_side_ == PositionSide::LONG + && position_entry_count_ == 1 && pyramiding_ == 0 + && pyramid_entries_.size() == 1 + && pyramid_entries_.front().entry_incarnation + == coof_market_entry_recalc_incarnation_ + && !from_entry.empty() + && from_entry == pyramid_entries_.front().entry_id + && !is_partial && std::isfinite(reserved_qty) + && std::abs(reserved_qty - position_qty_) <= kQtyEpsilon + && pending_orders_.empty() && oca_name.empty() + && !has_trail_request && slippage_ == 0 + && syminfo_.pointvalue == 1 && account_currency_fx_ == 1 + && account_currency_fx_timestamps_.empty() + && limit_marketable_at_coof_cursor + && internal::bar_path_uses_high_first(current_bar_) + && coof_cursor_price_ == bar_fill_price(current_bar_.high) + && current_bar_.low < order.limit_price + && order.limit_price < current_bar_.high + && (std::isnan(order.stop_price) + || order.stop_price < current_bar_.low); order.coof_suppress_stop_on_entry_bar = stop_marketable_at_coof_cursor; order.coof_suppress_limit_on_entry_bar = limit_marketable_at_coof_cursor - && !later_same_open_marketable_limit; + && !later_same_open_marketable_limit + && !first_high_market_limit_recross; } // KI-67 exit cascade (Model S). Record this mid-bar cascade exit's in-flight // leg so the historical dispatch gate can hold it on that leg's remainder, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ac00535..4eb645b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -175,6 +175,7 @@ set(TEST_SOURCES test_coof_cascade_eligibility test_coof_open_recalc_context test_cascade_exit_gapjump + test_coof_market_limit_recross test_pooc_position_visibility test_prearmed_exit_path_cursor test_prearmed_market_parent_gap_exit diff --git a/tests/test_coof_market_limit_recross.cpp b/tests/test_coof_market_limit_recross.cpp new file mode 100644 index 0000000..b88df80 --- /dev/null +++ b/tests/test_coof_market_limit_recross.cpp @@ -0,0 +1,174 @@ +// Round15 JOAT: five covered TradingView panels from r14-joat-audit. +// F carry/fresh CSV 5eff7824, high control 2308af1a; EUR carry f0cce2d5, +// high control 69197a4b. Six synthetic bars retain the two relevant OHLC +// legs and explicit quantities, without loading or replaying a strategy/feed. +// A MARKET reentry at W1=H may place a marketable full long limit. It waits +// through H->L, then fills at the limit on the L->C recross. An earlier +// terminal-W2 entry (F) or a final leg that cannot recross (EUR) still rolls. +#include +#include +#include +#include +#include +#include + +using namespace pineforge; +static int passed = 0, failed = 0; +#define CHECK(x) do { if (x) ++passed; else { \ + ++failed; std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ +} } while (0) + +namespace { +constexpr double N = std::numeric_limits::quiet_NaN(); +bool near(double a, double b) { return std::abs(a-b) < 1e-7; } +enum class Guard { None, RawParent, PricedParent, CompetingOrder, + DirectPartial, ReachableStop, LimitBelowLow }; + +class RecrossProbe : public BacktestEngine { +public: + RecrossProbe(bool eur, bool high = false, bool fresh = false, + Guard guard = Guard::None) + : eur_(eur), high_(high), fresh_(fresh), guard_(guard) { + initial_capital_ = 100000; + pyramiding_ = 0; + margin_long_ = margin_short_ = 100; + qty_step_ = eur ? .01 : 1; + syminfo_.pointvalue = 1; + set_syminfo_mintick(eur ? .00001 : .01); + commission_type_ = CommissionType::PERCENT; + commission_value_ = .01; + slippage_ = 0; + calc_on_order_fills_ = true; + process_orders_on_close_ = false; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT + && trade_count() == 0) + strategy_entry("L", true, N, N, eur_ ? 8389.91 : 840, "OLD"); + if (bar_index_ == 2 && position_side_ == PositionSide::FLAT + && trade_count() == 1) + strategy_entry("L", true, N, N, eur_ ? 8371.05 : 833, "REENTRY1"); + if (bar_index_ == 3 && position_side_ == PositionSide::FLAT + && trade_count() == 2) { + if (guard_ == Guard::RawParent) + strategy_order("L", true, 832); + else + strategy_entry("L", true, N, + guard_ == Guard::PricedParent ? 12.04 : N, + eur_ ? 8369.44 : 832, "REENTRY2"); + } + if (position_side_ == PositionSide::LONG) { + const bool last = trade_count() >= 2; + if (last && bar_index_ == 3 && guard_ == Guard::CompetingOrder) + strategy_order("Far", false, 1, 20.0); + if (last && trade_count() == 2 && bar_index_ == 3 + && guard_ == Guard::DirectPartial) + strategy_close("L", "PARTIAL", 1, N, true); + const double target = last && guard_ == Guard::LimitBelowLow ? 11.95 + : last && high_ ? (eur_ ? 1.175 : 12.20) + : (eur_ ? 1.173199565095035 : 12.011758862989522); + strategy_exit(last && fresh_ ? "FreshRisk" : "Risk", "L", + target, last && guard_ == Guard::ReachableStop ? 12.00 + : (eur_ ? 1.168520271815603 : 11.854525710631547), + N, N, N, 100, "TP"); + } + if (bar_index_ == 4) strategy_close("", "END"); + } + uint64_t fills() const { return broker_fill_event_seq_; } +private: + bool eur_, high_, fresh_; + Guard guard_; +}; + +std::vector bars(bool eur) { + if (eur) return { + {1.1703,1.1703,1.1703,1.1703,1,1000}, + {1.17033,1.171,1.1702,1.1705,1,2000}, + {1.17284,1.17322,1.17242,1.17318,1,3000}, + {1.17316,1.17342,1.1728,1.1734,1,4000}, + {1.1734,1.17461,1.17334,1.17454,1,5000}, + {1.17456,1.17508,1.17418,1.17418,1,6000}, + }; + return { + {11.9,11.9,11.9,11.9,1,1000}, + {11.92,11.93,11.91,11.92,1,2000}, + {11.99,12.03,11.975,12.01,1,3000}, + {12.005,12.04,11.965,12.035,1,4000}, + {12.035,12.05,12.02,12.045,1,5000}, + {12.045,12.06,12.01,12.015,1,6000}, + }; +} + +void check_panel(bool eur, bool high = false, bool fresh = false) { + RecrossProbe p(eur, high, fresh); + const auto b = bars(eur); + for (int repeat = 0; repeat < 2; ++repeat) { + p.run(b.data(), b.size()); + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 3); + CHECK(p.fills() == 6); + if (p.trade_count() != 3) continue; + const auto& first = p.get_trade(0); + const auto& second = p.get_trade(1); + const auto& last = p.get_trade(2); + CHECK(first.entry_bar_index == 1 && first.exit_bar_index == 2); + CHECK(second.entry_bar_index == 2 && second.exit_bar_index == 3); + CHECK(near(first.entry_price, eur ? 1.17033 : 11.92)); + CHECK(near(first.exit_price, eur ? 1.1732 : 12.02)); + CHECK(near(second.entry_price, eur ? 1.17322 : 12.03)); + CHECK(near(second.exit_price, eur ? 1.1732 : 12.02)); + CHECK(near(last.entry_price, eur ? 1.17342 : 12.04)); + CHECK(last.entry_bar_index == 3); + CHECK(near(last.qty, eur ? 8369.44 : 832)); + const double exit = high ? (eur ? 1.17456 : 12.05) + : (eur ? 1.1732 : 12.02); + CHECK(last.exit_bar_index == (high ? 5 : 3)); + CHECK(near(last.exit_price, exit)); + CHECK(last.exit_comment == (high ? "END" : "TP")); + if (!high) CHECK(last.exit_id == (fresh ? "FreshRisk" : "Risk")); + CHECK(near(last.commission, (last.entry_price + exit) * last.qty * .0001)); + CHECK(near(last.pnl, (exit - last.entry_price) * last.qty + - (last.entry_price + exit) * last.qty * .0001)); + } +} + +// These are scope guards checked against both the unchanged parent runtime +// and this candidate, not claims that the related unpinned TV shapes are fixed. +void check_guards() { + for (Guard g : {Guard::RawParent, Guard::PricedParent, Guard::CompetingOrder, + Guard::DirectPartial, Guard::ReachableStop, Guard::LimitBelowLow}) { + RecrossProbe p(false, false, false, g); + const auto b = bars(false); + p.run(b.data(), b.size()); + CHECK(p.last_error().empty()); + const bool partial = g == Guard::DirectPartial; + CHECK(p.trade_count() == (partial ? 4 : 3)); + CHECK(p.fills() == (partial ? 7 : 6)); + if (p.trade_count() != (partial ? 4 : 3)) continue; + const auto& last = p.get_trade(partial ? 3 : 2); + CHECK(last.entry_bar_index == 3); + CHECK(near(last.entry_price, 12.04)); + CHECK(near(last.qty, partial ? 831 : 832)); + CHECK(last.exit_bar_index == (g == Guard::ReachableStop ? 3 : 4)); + CHECK(near(last.exit_price, g == Guard::ReachableStop ? 11.97 : 12.04)); + if (partial) { + CHECK(near(p.get_trade(2).qty, 1)); + CHECK(p.get_trade(2).exit_bar_index == 3); + CHECK(near(p.get_trade(2).exit_price, 12.04)); + } + } +} +} // namespace + +int main(int argc, char**) { + if (argc == 1) { + check_panel(false); + check_panel(false, false, true); + check_panel(false, true); + check_panel(true); + check_panel(true, true); + } + check_guards(); + std::printf("%d passed, %d failed\n", passed, failed); + return failed ? 1 : 0; +}