From ff5e7c23b71a0aa5da0422612c92552f2e4e0f8f Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 11 Jul 2026 18:15:45 +0800 Subject: [PATCH] Freeze script-visible position through a same-bar POOC close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under process_orders_on_close=true, a strategy.close/close_all filling in-line mid-execution made strategy.position_size read the post-close value for the rest of that bar's script execution, so flat-gated entries later in the same execution were placed and filled — TV keeps the pre-close view until the next bar and never places them. Pinned by a clean-room probe (committed with its TV export): TV's 1,277 entries equal the blocked-reentry prediction exactly, with zero entries on exit bars, while the engine re-entered on 399 of 1,381; the same probe pinned the daily lookahead pivot value and the var-confirm machine as already TV-exact (1,245/1,245), so the visibility gate is the only divergence in this class. Mechanism: the pre-close side and quantity are frozen when an in-line POOC close fills (immediately=true excluded) and reported through signed_position_size() — which has no engine-internal callers, so broker behavior is byte-identical — until the bar's flush clears it, with defensive resets at run-init and coof recompute. The refuted cancel-extension is untouched; opposite-direction reversals (not flat-gated) still flip. Direct-field reads (bare opentrades count, position_avg_price value) are not frozen — documented residual pending a codegen accessor-routing pin. Corpus: 252/252, 240/11/1 zero movement; POOC probe family exact. Private sweeps: targeted and full 412 both 0 up / 0 down. Co-Authored-By: Claude Fable 5 --- include/pineforge/engine.hpp | 47 ++++ src/engine_run.cpp | 2 + src/engine_strategy_commands.cpp | 15 ++ tests/CMakeLists.txt | 1 + tests/test_pooc_position_visibility.cpp | 297 ++++++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 tests/test_pooc_position_visibility.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 9392937..4a7c213 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -493,6 +493,27 @@ class BacktestEngine { std::string sb_close_comment_; // surviving order's comment std::unordered_map close_reserved_qty_; + // --- KI-64: POOC script-visible position freeze --- + // Under process_orders_on_close, a strategy.close/close_all that fills + // IN-LINE (execute_immediate_close) mutates the broker position mid-on_bar, + // but TradingView keeps the SCRIPT position accessor + // ``strategy.position_size`` (signed_position_size(), and the + // opentrades/position_avg_price na-guards derived from it) reporting the + // PRE-close position until the NEXT bar. While ``pos_view_freeze_bar_ == + // bar_index_`` the script-facing signed_position_size() returns this frozen + // snapshot; broker / order state and every internal position_qty_ / + // position_side_ read (order sizing, affordability, reversal qty, exit + // sizing) are UNAFFECTED. Armed in strategy_close on the ordinary POOC + // immediate-close path (NOT immediately=true, which is defined to reflect + // its fill at once); cleared at the top of flush_same_bar_close() — the + // first thing after every POOC on_bar — so step-4 and post-run reads see + // the real position. The bar_index_ scoping is a defensive backstop: the + // snapshot auto-expires when the script advances a bar even if a clear site + // is ever missed. + int pos_view_freeze_bar_ = -1; + PositionSide pos_view_frozen_side_ = PositionSide::FLAT; + double pos_view_frozen_qty_ = 0.0; + // --- Strategy parameters (set from strategy() declaration) --- double initial_capital_ = 1000000.0; bool process_orders_on_close_ = false; @@ -1161,11 +1182,37 @@ class BacktestEngine { // --- Strategy variable accessors --- double signed_position_size() const { + // KI-64: while a POOC same-bar in-line close is frozen for this bar, + // the SCRIPT sees the pre-close position (TV defers close visibility to + // the next bar). Broker/internal reads use position_side_/position_qty_ + // directly and are unaffected. + if (pos_view_freeze_bar_ == bar_index_) { + if (pos_view_frozen_side_ == PositionSide::LONG) return pos_view_frozen_qty_; + if (pos_view_frozen_side_ == PositionSide::SHORT) return -pos_view_frozen_qty_; + return 0.0; + } if (position_side_ == PositionSide::LONG) return position_qty_; if (position_side_ == PositionSide::SHORT) return -position_qty_; return 0.0; } + // KI-64: freeze the pre-close position for the script-visible position + // accessor before an ordinary POOC strategy.close/close_all fills in-line + // this bar. Capture-once per on_bar (a second same-bar close keeps the + // FIRST pre-close snapshot). Caller guards process_orders_on_close_ && + // !immediately; this reads position_side_/position_qty_ while they still + // hold the pre-close values (execute_immediate_close has not run yet). + void freeze_script_position_view() { + if (pos_view_freeze_bar_ == bar_index_) return; + pos_view_freeze_bar_ = bar_index_; + pos_view_frozen_side_ = position_side_; + pos_view_frozen_qty_ = position_qty_; + } + // KI-64: release the freeze so the next script-visible read returns the real + // (post-close) position. Called at the top of flush_same_bar_close(), i.e. + // immediately after every POOC on_bar returns. + void clear_script_position_view() { pos_view_freeze_bar_ = -1; } + double net_profit() const { return net_profit_sum_; } double gross_profit() const { return gross_profit_sum_; } double gross_loss() const { return gross_loss_sum_; } diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 61aada5..af82ea7 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -129,6 +129,7 @@ uint64_t BacktestEngine::execute_coof_script_body( is_last_tick_ = true; history_slot_is_new_ = !coof_checkpoint_contains_current_bar_; pending_close_qty_in_bar_ = 0.0; + pos_view_freeze_bar_ = -1; // KI-64: recompute re-arms the freeze fresh _push_source_series(); update_per_trade_extremes(); @@ -366,6 +367,7 @@ void BacktestEngine::reset_run_state() { // pyramid_entries_, trail, partial ids pending_orders_.clear(); pending_close_qty_in_bar_ = 0.0; + pos_view_freeze_bar_ = -1; // KI-64: fresh run starts with no frozen view sb_close_active_ = false; sb_close_bar_ = -1; sb_close_calls_ = 0; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 1117f5f..f8e9115 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -397,6 +397,14 @@ void BacktestEngine::strategy_close(const std::string& id, const std::string& co if ((pooc_can_fill_at_this_cursor || immediately) && !(coof_scheduler_active_ && coof_direct_fill_events_remaining_ == 0)) { + // KI-64: for an ORDINARY POOC close (not immediately=true, which is + // defined to reflect its fill at once) freeze the script-visible + // position BEFORE execute_immediate_close mutates it, so a later + // strategy.position_size gate in THIS bar still sees the pre-close + // position. Broker/order side effects below are unchanged. + if (process_orders_on_close_ && !immediately) { + freeze_script_position_view(); + } execute_immediate_close(id, comment, qty_to_close, matching_qty, closes_full_position, closes_fifo_qty, closes_any_qty); return; @@ -513,6 +521,13 @@ void BacktestEngine::enqueue_same_bar_close(const std::string& id, // the magnifier's last tick), i.e. the same bar and price the immediate // path used, after the strategy's on_bar has fully run. void BacktestEngine::flush_same_bar_close() { + // KI-64: on_bar has returned — release any POOC script-visible position + // freeze so the flush below, step-4 order processing, and post-run reads + // all observe the real (post-close) position. Runs on every POOC bar + // (flush is the first call after each POOC on_bar), before the early + // return so a bar with an in-line close but no enqueued survivor still + // clears. Broker reads here use position_side_/position_qty_ directly. + clear_script_position_view(); if (!sb_close_active_) return; const std::string id = sb_close_id_; const std::string comment = sb_close_comment_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4041f4e..9d4e39f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,7 @@ set(TEST_SOURCES test_margin_call test_streaming test_calc_on_order_fills + test_pooc_position_visibility ) find_package(Threads REQUIRED) diff --git a/tests/test_pooc_position_visibility.cpp b/tests/test_pooc_position_visibility.cpp new file mode 100644 index 0000000..0051ce1 --- /dev/null +++ b/tests/test_pooc_position_visibility.cpp @@ -0,0 +1,297 @@ +/* + * test_pooc_position_visibility.cpp — KI-64 successor. + * + * THE RULE (process_orders_on_close=true only): during bar i's script + * execution, ``strategy.position_size`` (signed_position_size()) must report + * the position as it stood BEFORE any same-bar close fills. A + * strategy.close/close_all ordered earlier in bar i fills at bar i's close per + * POOC, but its effect on script-visible position state becomes visible only + * from bar i+1. Broker/order state (position_side_, position_qty_, trades_) + * mutates immediately as before — only the SCRIPT-facing accessor defers. + * + * Ground truth: data/probes/pf-probe-ki64-daypivot-crossover — TV places 0 + * entries on the 1,278 exit bars (a flat-gated strategy.entry on a close_all + * bar is never placed); the pre-fix engine re-enters on 399/1,381 exit bars + * because close_all flips position_size to 0 mid-on_bar. + * + * R rows are RED vs worktree HEAD 8b5932f (engine flips visibility). G rows + * are characterization that must hold before AND after the fix: + * - POOC=false is unchanged (the close is a deferred market exit that fills + * next bar, so the position is never mutated mid-on_bar — no freeze needed). + * - an entry gated on position_size != 0 placed BEFORE the close still fires. + * - strategy.close(immediately=true) is DEFINED to reflect its fill at once, + * so it is NOT deferred (the :3896 same-dir immediate-cancel pin holds). + * - a NON-flat-gated opposite entry (reversal) still flips (affordable + * reversal class: sharpstrat/raphaeltay). + * - the freeze is scoped to the close bar: next-bar and post-run reads see + * the real (post-close) position. + */ + +#include +#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(); + +static Bar mk(double c, int64_t ts) { + Bar b; + b.open = c; b.high = c; b.low = c; b.close = c; + b.volume = 1000.0; b.timestamp = ts; + return b; +} + +// Flat OHLCV series (price 100 throughout) — isolates order/position mechanics +// from fill-price effects. Under POOC a market order placed in bar i's on_bar +// fills at bar i's close. +static Bar bars4[4] = { + mk(100, 600'000), mk(100, 1'200'000), mk(100, 1'800'000), mk(100, 2'400'000), +}; + +// ───────────────────────────────────────────────────────────────────── +// Shared probe kernel (mirrors the daypivot probe): enter while flat, then on +// the NEXT bar close_all and immediately re-test the flat gate. ``opener`` +// selects how the close is issued so both the close_all and the +// strategy.close(id) paths through execute_immediate_close are exercised. +// ───────────────────────────────────────────────────────────────────── +enum class CloseKind { CloseAll, CloseIdAny }; + +class ProbeKernel : public BacktestEngine { +public: + CloseKind kind; + int entry_bar = -1; + int entries_placed = 0; + double gate_pos_on_close_bar = -999.0; // position_size the flat gate saw on bar1 + double pos_on_next_bar = -999.0; // position_size at start of bar2 + + explicit ProbeKernel(CloseKind k, bool pooc) : kind(k) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + process_orders_on_close_ = pooc; + if (k == CloseKind::CloseIdAny) close_entries_rule_any_ = true; + } + void issue_close() { + if (kind == CloseKind::CloseAll) strategy_close_all(); + else strategy_close("L"); // any-rule full close + } + void on_bar(const Bar&) override { + if (bar_index_ == 2) pos_on_next_bar = signed_position_size(); + // close trigger: one bar after entry (probe's `bar_index > entry_bar`) + if (signed_position_size() != 0.0 && entry_bar >= 0 && bar_index_ > entry_bar) { + issue_close(); + } + // flat-gated entry, armed on bars 0 and 1 (sig_up in the probe) + if (bar_index_ == 1) gate_pos_on_close_bar = signed_position_size(); + if (signed_position_size() == 0.0 && (bar_index_ == 0 || bar_index_ == 1)) { + strategy_entry("L", true); + entry_bar = bar_index_; + ++entries_placed; + } + } + double ssize() const { return signed_position_size(); } +}; + +// R1 — POOC close_all: the flat gate on the close bar must see the PRE-close +// LONG 1 and NOT re-enter. RED pre-fix: gate sees 0, re-enters (2 entries). +static void test_R1_pooc_closeall_flat_gate_blocks_reentry() { + std::printf("R1: POOC close_all — flat-gated entry blocked on close bar\n"); + ProbeKernel p(CloseKind::CloseAll, /*pooc=*/true); + p.run(bars4, 4); + CHECK(near(p.gate_pos_on_close_bar, 1.0)); // FROZEN pre-close (RED: 0.0) + CHECK(p.entries_placed == 1); // no bar1 re-entry (RED: 2) +} + +// R2 — POOC strategy.close(id) that routes through execute_immediate_close +// (close_entries_rule=ANY full close). Same rule as R1. +static void test_R2_pooc_closeid_flat_gate_blocks_reentry() { + std::printf("R2: POOC strategy.close(id) — flat-gated entry blocked\n"); + ProbeKernel p(CloseKind::CloseIdAny, /*pooc=*/true); + p.run(bars4, 4); + CHECK(near(p.gate_pos_on_close_bar, 1.0)); // FROZEN pre-close (RED: 0.0) + CHECK(p.entries_placed == 1); // no bar1 re-entry (RED: 2) +} + +// G1 — POOC=false characterization: the close is a DEFERRED market exit that +// fills next bar's open, so position_size is never mutated mid-on_bar; the flat +// gate already sees the open position. Same numeric outcome as fixed POOC, via +// a different mechanism. Must be UNCHANGED by the fix (freeze never arms). +static void test_G1_non_pooc_unchanged() { + std::printf("G1: POOC=false — deferred close, flat gate sees open (unchanged)\n"); + ProbeKernel p(CloseKind::CloseAll, /*pooc=*/false); + p.run(bars4, 4); + CHECK(near(p.gate_pos_on_close_bar, 1.0)); // real open position (never mutated) + CHECK(p.entries_placed == 1); +} + +// G2 — an entry gated on position_size != 0 placed BEFORE the close call in the +// same bar must still fire (the freeze arms only AT the close). pyramiding=2. +static void test_G2_pooc_entry_before_close_still_fires() { + std::printf("G2: POOC — position_size!=0 entry BEFORE close still fires\n"); + class Strat : public BacktestEngine { + public: + bool add_placed = false; + Strat() { + initial_capital_ = 1'000'000; default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; commission_value_ = 0.0; slippage_ = 0; + pyramiding_ = 2; process_orders_on_close_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (bar_index_ == 1 && signed_position_size() != 0.0) { + strategy_entry("L_add", true); // gated on != 0, BEFORE the close + add_placed = true; + strategy_close_all(); + } + } + }; + Strat s; s.run(bars4, 4); + CHECK(s.add_placed); // the != 0 gate saw the real LONG before the close +} + +// G3 — strategy.close(immediately=true) is DEFINED to reflect its fill at once, +// so it must NOT be deferred: the mid-bar read is 0 and the prior same-dir +// market re-entry is cancelled (test_integration :3896 shape). pyramiding=2. +static void test_G3_pooc_immediately_not_deferred() { + std::printf("G3: POOC immediately=true — NOT deferred (visible at once)\n"); + class Strat : public BacktestEngine { + public: + double mid_bar_pos = -999.0; + Strat() { + initial_capital_ = 1'000'000; default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; commission_value_ = 0.0; slippage_ = 0; + pyramiding_ = 2; process_orders_on_close_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (bar_index_ == 1 && signed_position_size() > 0.0) { + strategy_entry("L_add", true); + strategy_close("L", "", kNaN, kNaN, /*immediately=*/true); + mid_bar_pos = signed_position_size(); // immediate: reads 0 + } + } + double ssize() const { return signed_position_size(); } + }; + Strat s; s.run(bars4, 4); + CHECK(near(s.mid_bar_pos, 0.0)); // immediate=true is visible at once + CHECK(s.trade_count() == 1); // L_add same-dir re-entry cancelled + CHECK(near(s.ssize(), 0.0)); // ends flat +} + +// G5a — pure reversal: a NON-flat-gated opposite entry while LONG flips to +// SHORT under POOC (affordable-reversal class). No close, no freeze. +static void test_G5a_pooc_pure_reversal_flips() { + std::printf("G5a: POOC — opposite entry (no close) flips LONG->SHORT\n"); + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 1'000'000; default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; commission_value_ = 0.0; slippage_ = 0; + process_orders_on_close_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (bar_index_ == 1 && signed_position_size() > 0.0) + strategy_entry("S", false); // reversal, NOT gated on == 0 + } + double ssize() const { return signed_position_size(); } + }; + Strat s; s.run(bars4, 4); + CHECK(s.ssize() < 0.0); // flipped to SHORT +} + +// G5b — close_all THEN an unconditional opposite entry same bar: the freeze +// must NOT block the reversal (S is not flat-gated). Ends SHORT. +static void test_G5b_pooc_closeall_then_opposite_entry_flips() { + std::printf("G5b: POOC — close_all + opposite entry still flips to SHORT\n"); + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 1'000'000; default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; commission_value_ = 0.0; slippage_ = 0; + process_orders_on_close_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (bar_index_ == 1 && signed_position_size() > 0.0) { + strategy_close_all(); + strategy_entry("S", false); // opposite, unconditional + } + } + double ssize() const { return signed_position_size(); } + }; + Strat s; s.run(bars4, 4); + CHECK(s.ssize() < 0.0); // reversal survived the freeze +} + +// G6 — next-bar visibility: after a close flattens on bar1, bar2's on_bar reads +// the real FLAT position (freeze is scoped to the arming bar). +static void test_G6_pooc_next_bar_reads_flat() { + std::printf("G6: POOC — next bar reads the real (flat) position\n"); + ProbeKernel p(CloseKind::CloseAll, /*pooc=*/true); + p.run(bars4, 4); + CHECK(near(p.pos_on_next_bar, 0.0)); // bar2 sees post-close FLAT +} + +// G7 — post-run read after a close on the LAST bar must return the real +// (flat) position, not the frozen snapshot (guards the flush-time clear). +static void test_G7_pooc_post_run_read_is_real() { + std::printf("G7: POOC — post-run read after last-bar close_all is FLAT\n"); + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 1'000'000; default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; commission_value_ = 0.0; slippage_ = 0; + process_orders_on_close_ = true; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", true); + if (bar_index_ == 1 && signed_position_size() > 0.0) strategy_close_all(); + } + double ssize() const { return signed_position_size(); } + }; + Strat s; + Bar bars2[2] = { mk(100, 600'000), mk(100, 1'200'000) }; // close on last bar + s.run(bars2, 2); + CHECK(near(s.ssize(), 0.0)); // post-run: real flat (RED-if-broken: 1.0 frozen) +} + +int main() { + test_R1_pooc_closeall_flat_gate_blocks_reentry(); + test_R2_pooc_closeid_flat_gate_blocks_reentry(); + test_G1_non_pooc_unchanged(); + test_G2_pooc_entry_before_close_still_fires(); + test_G3_pooc_immediately_not_deferred(); + test_G5a_pooc_pure_reversal_flips(); + test_G5b_pooc_closeall_then_opposite_entry_flips(); + test_G6_pooc_next_bar_reads_flat(); + test_G7_pooc_post_run_read_is_real(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}