From f2dbbe121f82aaf582f6ae70570e9dc878460df1 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 6 Sep 2026 22:04:32 +0800 Subject: [PATCH 1/2] fix: use broker close tick for pinned POOC short exits --- include/pineforge/engine.hpp | 5 + src/engine_fills.cpp | 52 +++++++-- src/engine_strategy_commands.cpp | 2 + tests/CMakeLists.txt | 1 + tests/test_pooc_short_close_tick.cpp | 152 +++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 tests/test_pooc_short_close_tick.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 9954766..ea5451b 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -777,6 +777,9 @@ struct PendingOrder { std::numeric_limits::quiet_NaN(); std::string comment; // order comment for trade reporting bool requested_partial = false; // true iff caller passed qty_percent < 100 + // Preserve the original default/full-percent EXIT call before reservation + // normalization can turn a sub-lot partial request into a full-size order. + bool full_percent_exit_request = false; // Narrow POOC global-full-exit candidate. ``qty`` deliberately keeps the // normal finite reservation so sibling exits see and respect its capacity. // At fill time this bit upgrades that one reservation to the full live @@ -3795,6 +3798,8 @@ class BacktestEngine { // apply (mutate engine state with the fill — see apply_*_order_fill // declarations above). enum class OrderEligibility { Proceed, Skip, Remove }; + double pooc_short_exit_trigger_close(const PendingOrder& order, + const Bar& bar) const; OrderEligibility classify_order_eligibility( PendingOrder& order, int opposing_pass, internal::DualEntryStopPathWinner dual_entry_path, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 341ebdc..7e47508 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -7199,6 +7199,44 @@ void BacktestEngine::mark_position_brackets_dormant_on_declined_reversal(const B // Returns whether the given pending order should be processed this // iteration. Walks the chain of TV-empirical "skip" / "cancel" rules // in source order; the first rule to fire dictates the verdict. +double BacktestEngine::pooc_short_exit_trigger_close( + const PendingOrder& order, const Bar& bar) const { + // Hariss F POOC pins: newly reissued short exits test the broker's tick + // close, while Pine still sees raw OHLC and the order levels stay raw. + // C11.575 ->11.58 skips limit11.576782; C11.695 ->11.70 reaches + // stop11.698693; C12.495 ->12.50 reaches stop12.496973. Both admission + // and fill evaluation must use the same close and never an elapsed wick. + const bool pinned_reissue = process_orders_on_close_ + && !calc_on_order_fills_ && !coof_scheduler_active_ + && !bar_magnifier_enabled_ && !stream_warmup_mode_ + && stream_phase_ == StreamPhase::IDLE + && position_side_ == PositionSide::SHORT + && position_open_bar_ >= 0 && position_open_bar_ < bar_index_ + && position_entry_count_ == 1 && pyramiding_ == 0 + && pyramid_entries_.size() == 1 && pending_orders_.size() == 1 + && order.type == OrderType::EXIT && !order.is_long + && order.created_bar == bar_index_ && !order.created_during_coof_recalc + && order.created_by_same_id_replacement + && order.replaced_exit_order_incarnation != 0 + && order.created_while_in_position && !order.dormant_bracket + && !order.from_entry.empty() + && order.from_entry == pyramid_entries_.front().entry_id + && order.full_percent_exit_request + && !order.requested_partial && order.qty_percent == 100.0 + && std::isfinite(order.qty) + && std::abs(order.qty - position_qty_) <= kQtyEpsilon + && order.oca_name.empty() + && std::isnan(order.trail_points) && std::isnan(order.trail_price) + && std::isnan(order.trail_offset) + && slippage_ == 0 && commission_type_ == CommissionType::PERCENT + && syminfo_.pointvalue == 1 && account_currency_fx_ == 1 + && account_currency_fx_timestamps_.empty() + && max_intraday_filled_orders_ == 0 + && risk_max_intraday_loss_ == 0 && risk_max_drawdown_ == 0 + && risk_max_cons_loss_days_ == 0; + return pinned_reissue ? tick_grid_price(bar.close) : bar.close; +} + BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( PendingOrder& order, int opposing_pass, internal::DualEntryStopPathWinner dual_entry_path, @@ -7464,16 +7502,17 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( && !has_stop_or_trail && !std::isnan(order.limit_price); bool exit_marketable_at_close = false; + const double trigger_close = pooc_short_exit_trigger_close(order, bar); if (exit_style && std::isnan(order.trail_points) && std::isnan(order.trail_price)) { if (!std::isnan(order.stop_price)) { exit_marketable_at_close = order.is_long - ? (bar.close <= order.stop_price) - : (bar.close >= order.stop_price); + ? (trigger_close <= order.stop_price) + : (trigger_close >= order.stop_price); } if (!exit_marketable_at_close && !std::isnan(order.limit_price)) { exit_marketable_at_close = order.is_long - ? (bar.close >= order.limit_price) - : (bar.close <= order.limit_price); + ? (trigger_close >= order.limit_price) + : (trigger_close <= order.limit_price); } } if (!pure_limit_entry && !exit_marketable_at_close @@ -7653,10 +7692,11 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( // against it; the close is the earliest (and only) point in this // bar it could have interacted with the market. bool is_long = position_side_ == PositionSide::LONG; + const double trigger_close = pooc_short_exit_trigger_close(order, bar); bool stop_marketable = has_stop - && (is_long ? (bar.close <= stop_price) : (bar.close >= stop_price)); + && (is_long ? (trigger_close <= stop_price) : (trigger_close >= stop_price)); bool limit_marketable = has_limit - && (is_long ? (bar.close >= limit_price) : (bar.close <= limit_price)); + && (is_long ? (trigger_close >= limit_price) : (trigger_close <= limit_price)); if (stop_marketable) { // Exit stop for a LONG is a SELL (worse execution = lower // price); for a SHORT it's a BUY (worse = higher price) -- diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index c662664..16d7020 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -2164,6 +2164,8 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.qty_type = -1; order.qty_percent = qp; order.requested_partial = is_partial; + order.full_percent_exit_request = !has_explicit_qty + && (std::isnan(qty_percent) || qty_percent == 100.0); order.pooc_global_full_exit_dynamic_qty = bind_global_full_exit_dynamic_qty; order.pooc_global_full_exit_tracks_bound_adds = diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4eb645b..fe4c1a2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,6 +177,7 @@ set(TEST_SOURCES test_cascade_exit_gapjump test_coof_market_limit_recross test_pooc_position_visibility + test_pooc_short_close_tick test_prearmed_exit_path_cursor test_prearmed_market_parent_gap_exit test_prearmed_bracket_fill_bar diff --git a/tests/test_pooc_short_close_tick.cpp b/tests/test_pooc_short_close_tick.cpp new file mode 100644 index 0000000..2ef3407 --- /dev/null +++ b/tests/test_pooc_short_close_tick.cpp @@ -0,0 +1,152 @@ +// Round16 Hariss F: original TV rows 114/118/363, source and feed pinned in +// r16-20260906/readback-receipt.json. Cloud Run diagnostic captures the new +// stop/limit prices. The broker tests its tick close against the raw level: +// Sep3 C11.575 ->11.58 skips L11.576782; Sep8 C11.695 ->11.70 reaches +// S11.698693; Apr23 C12.495 ->12.50 reaches S12.496973. Existing resting +// levels miss those bars; the newly reissued close-time exit owns the fill. +// Four synthetic bars isolate each event, without loading strategy/feed data. +#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-8; } +enum class Guard { None, FreshId, Competing, Partial, Long, Coof, + NonPooc, Slip, Fx, EntryBar }; + +struct Panel { + double entry, old_stop, old_limit, new_stop, new_limit; + double next_stop, next_limit, expected_exit; + int expected_bar; + std::vector bars; +}; + +Panel panel(int n) { + if (n == 0) return {11.69,11.747887,11.574226,11.746609,11.576782, + 11.743280,11.583440,11.58,3,{ + {11.69,11.69,11.69,11.69,1,1000}, + {11.615,11.615,11.595,11.595,1,2000}, + {11.595,11.595,11.575,11.575,1,3000}, + {11.58,11.58,11.575,11.575,1,4000}}}; + if (n == 1) return {11.64,11.700131,11.519738,11.698693,11.522614, + 11.69900,11.52200,11.70,2,{ + {11.64,11.64,11.64,11.64,1,1000}, + {11.685,11.70,11.685,11.69,1,2000}, + {11.69,11.70,11.68,11.695,1,3000}, + {11.695,11.695,11.66,11.665,1,4000}}}; + return {12.41,12.500586,12.228828,12.496973,12.236054, + 12.49700,12.23600,12.50,2,{ + {12.41,12.41,12.41,12.41,1,1000}, + {12.46,12.48,12.45,12.48,1,2000}, + {12.48,12.50,12.48,12.495,1,3000}, + {12.50,12.515,12.47,12.48,1,4000}}}; +} + +class CloseTickProbe : public BacktestEngine { +public: + CloseTickProbe(Panel data, Guard guard = Guard::None) + : p_(std::move(data)), guard_(guard) { + initial_capital_ = 100000; + margin_long_ = margin_short_ = 100; + pyramiding_ = 0; + qty_step_ = 1; + syminfo_.pointvalue = 1; + set_syminfo_mintick(.01); + commission_type_ = CommissionType::PERCENT; + commission_value_ = .05; + process_orders_on_close_ = guard != Guard::NonPooc; + calc_on_order_fills_ = guard == Guard::Coof; + slippage_ = guard == Guard::Slip ? 1 : 0; + account_currency_fx_ = guard == Guard::Fx ? 2 : 1; + } + void on_bar(const Bar& b) override { + const int seed_bar = guard_ == Guard::EntryBar ? 2 : 0; + if (bar_index_ == seed_bar && position_side_ == PositionSide::FLAT + && trades_.empty()) + strategy_entry("E", guard_ == Guard::Long, N, N, 1); + if (bar_index_ >= 1 && position_side_ != PositionSide::FLAT) { + if (bar_index_ == 2 && guard_ == Guard::FreshId) + strategy_cancel("X"); + if (guard_ == Guard::Competing) + strategy_order("Idle", true, 1, N, 1000); + const double stop = bar_index_ == 1 ? p_.old_stop + : (bar_index_ == 2 ? p_.new_stop : p_.next_stop); + const double limit = bar_index_ == 1 ? p_.old_limit + : (bar_index_ == 2 ? p_.new_limit : p_.next_limit); + strategy_exit("X", "E", limit, stop, N, N, N, + guard_ == Guard::Partial ? 50 : 100, "X"); + } + seen_close = b.close; + } + double remaining() const { return position_qty_; } + uint64_t fills() const { return broker_fill_event_seq_; } + double seen_close = N; +private: + Panel p_; + Guard guard_; +}; + +void positive(int n) { + const auto d = panel(n); + CloseTickProbe p(d); + for (int repeat = 0; repeat < 2; ++repeat) { + p.run(d.bars.data(), d.bars.size()); + CHECK(p.last_error().empty()); + CHECK(p.trade_count() == 1); + CHECK(p.fills() == 2); + CHECK(near(p.seen_close, d.bars.back().close)); + if (p.trade_count() != 1) continue; + const auto& t = p.get_trade(0); + CHECK(t.entry_bar_index == 0); + CHECK(t.exit_bar_index == d.expected_bar); + CHECK(near(t.entry_price, d.entry)); + CHECK(near(t.exit_price, d.expected_exit)); + CHECK(near(t.qty, 1)); + CHECK(!t.is_long); + CHECK(t.exit_id == "X"); + CHECK(near(t.commission, (d.entry+d.expected_exit)*.0005)); + CHECK(near(t.pnl, d.entry-d.expected_exit-t.commission)); + } +} + +// Signatures are compared with the unchanged parent's matching headers/lib. +// These excluded synthetic inputs characterize existing behavior only. +void guards() { + for (Guard g : {Guard::FreshId, Guard::Competing, Guard::Partial, + Guard::Long, Guard::Coof, Guard::NonPooc, Guard::Slip, + Guard::Fx, Guard::EntryBar}) { + for (int n = 0; n < 3; ++n) { + const auto d = panel(n); + CloseTickProbe p(d, g); + p.run(d.bars.data(), d.bars.size()); + CHECK(p.last_error().empty()); + std::printf("guard %d panel %d trades %d fills %llu remaining %.9f", + static_cast(g), n, p.trade_count(), + static_cast(p.fills()), p.remaining()); + for (int i = 0; i < p.trade_count(); ++i) { + const auto& t = p.get_trade(i); + std::printf(" | %d,%d,%.9f,%.9f,%.9f,%.9f", + t.entry_bar_index,t.exit_bar_index,t.entry_price,t.exit_price,t.qty,t.pnl); + } + std::puts(""); + } + } +} +} // namespace + +int main(int argc, char**) { + if (argc == 1) for (int i = 0; i < 3; ++i) positive(i); + guards(); + std::printf("%d passed, %d failed\n", passed, failed); + return failed ? 1 : 0; +} From 113ec87258f02cfee12db57f89b5631b0bb4bda9 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 6 Sep 2026 22:16:37 +0800 Subject: [PATCH 2/2] fix: ignore unbound exit siblings in POOC close tick guard --- src/engine_fills.cpp | 16 ++++++++++++++-- tests/test_pooc_short_close_tick.cpp | 18 +++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 7e47508..d5a13ec 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -7213,7 +7213,7 @@ double BacktestEngine::pooc_short_exit_trigger_close( && position_side_ == PositionSide::SHORT && position_open_bar_ >= 0 && position_open_bar_ < bar_index_ && position_entry_count_ == 1 && pyramiding_ == 0 - && pyramid_entries_.size() == 1 && pending_orders_.size() == 1 + && pyramid_entries_.size() == 1 && order.type == OrderType::EXIT && !order.is_long && order.created_bar == bar_index_ && !order.created_during_coof_recalc && order.created_by_same_id_replacement @@ -7234,7 +7234,19 @@ double BacktestEngine::pooc_short_exit_trigger_close( && max_intraday_filled_orders_ == 0 && risk_max_intraday_loss_ == 0 && risk_max_drawdown_ == 0 && risk_max_cons_loss_days_ == 0; - return pinned_reissue ? tick_grid_price(bar.close) : bar.close; + if (!pinned_reissue) return bar.close; + for (const PendingOrder& other : pending_orders_) { + if (&other == &order) continue; + // Hariss emits both directional EXITs at every close. An unbound + // sibling is removed by the existing position-cycle liveness gate; + // it cannot compete with this live exit. Entries, RAW orders, global + // exits and any sibling whose parent filled this cycle still exclude. + const bool unbound_exit = other.type == OrderType::EXIT + && !other.from_entry.empty() + && cycle_filled_entry_ids_.count(other.from_entry) == 0; + if (!unbound_exit) return bar.close; + } + return tick_grid_price(bar.close); } BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( diff --git a/tests/test_pooc_short_close_tick.cpp b/tests/test_pooc_short_close_tick.cpp index 2ef3407..a6e216b 100644 --- a/tests/test_pooc_short_close_tick.cpp +++ b/tests/test_pooc_short_close_tick.cpp @@ -54,8 +54,8 @@ Panel panel(int n) { class CloseTickProbe : public BacktestEngine { public: - CloseTickProbe(Panel data, Guard guard = Guard::None) - : p_(std::move(data)), guard_(guard) { + CloseTickProbe(Panel data, Guard guard = Guard::None, bool unbound = false) + : p_(std::move(data)), guard_(guard), unbound_(unbound) { initial_capital_ = 100000; margin_long_ = margin_short_ = 100; pyramiding_ = 0; @@ -83,6 +83,10 @@ class CloseTickProbe : public BacktestEngine { : (bar_index_ == 2 ? p_.new_stop : p_.next_stop); const double limit = bar_index_ == 1 ? p_.old_limit : (bar_index_ == 2 ? p_.new_limit : p_.next_limit); + // The real source issues both directional brackets each close. + // This other parent never opened in this position cycle. + if (unbound_) + strategy_exit("Opposite", "Other", limit, stop); strategy_exit("X", "E", limit, stop, N, N, N, guard_ == Guard::Partial ? 50 : 100, "X"); } @@ -94,11 +98,12 @@ class CloseTickProbe : public BacktestEngine { private: Panel p_; Guard guard_; + bool unbound_; }; -void positive(int n) { +void positive(int n, bool unbound) { const auto d = panel(n); - CloseTickProbe p(d); + CloseTickProbe p(d, Guard::None, unbound); for (int repeat = 0; repeat < 2; ++repeat) { p.run(d.bars.data(), d.bars.size()); CHECK(p.last_error().empty()); @@ -145,7 +150,10 @@ void guards() { } // namespace int main(int argc, char**) { - if (argc == 1) for (int i = 0; i < 3; ++i) positive(i); + if (argc == 1) for (int i = 0; i < 3; ++i) { + positive(i, false); + positive(i, true); + } guards(); std::printf("%d passed, %d failed\n", passed, failed); return failed ? 1 : 0;