From bbb9f6cdaad5215a826961a6b32e79835490a59d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 30 Jun 2026 21:28:51 +0800 Subject: [PATCH] fix(orders): defer process_orders_on_close market entry off the entry bar Under process_orders_on_close, strategy.entry market orders were filled immediately at the bar close (to keep strategy.position_avg_price correct for a following strategy.exit). But that made a same-bar bias-exit fire on the entry bar, producing zero-duration trades TradingView never emits: TV processes the market entry on close, then evaluates exits on the NEXT bar. Remove the immediate same-bar execute_market_entry for plain market entries under POOC; the order goes through the normal queue and the exit is evaluated the following bar, matching TV's calc-then-fill ordering. Priced (limit/stop) entries are unaffected. officialjackofalltrades-quant-synthesis: 23 zero-duration trades -> 0; strict entry|exit match 65.0%->72.8%, price-exact 96.9%->100%. Corpus held 251 excellent / 1 anomaly; ctest 76/76 incl. test_pooc_exit_not_triggered_on_entry_bar (reverting the fix makes it fail). No new C-ABI export. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/engine_strategy_commands.cpp | 9 ---- tests/test_integration.cpp | 83 ++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 059f35a..5cebcff 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -205,15 +205,6 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, bool has_stop = !std::isnan(stop_price); if (!has_limit && !has_stop) { - if (process_orders_on_close_) { - // With process_orders_on_close, market entries fill immediately at bar close - // so that strategy.position_avg_price is correct for subsequent strategy.exit() calls - double fill = current_bar_.close; - execute_market_entry(id, is_long, fill, qty, qty_type, position_side_); - // Set entry comment on the just-created pyramid entry - if (!pyramid_entries_.empty()) pyramid_entries_.back().entry_comment = comment; - return; - } order.type = OrderType::MARKET; order.limit_price = std::numeric_limits::quiet_NaN(); order.stop_price = std::numeric_limits::quiet_NaN(); diff --git a/tests/test_integration.cpp b/tests/test_integration.cpp index 2c22432..c165ff4 100644 --- a/tests/test_integration.cpp +++ b/tests/test_integration.cpp @@ -2537,22 +2537,84 @@ static void test_position_reversal_state() { }; strat.run(bars, 6); - // Bar 0: long entered at 100 - CHECK(near(strat.pos_size[0], 1.0)); - CHECK(near(strat.pos_avg[0], 100.0)); - - // Bar 2: reversed — long closed at 110 (pnl=+10), short opened at 110 - CHECK(near(strat.pos_size[2], -1.0)); - CHECK(near(strat.pos_avg[2], 110.0)); - CHECK(near(strat.net_pnl[2], 10.0)); // from closed long - - // Bar 4: short closed at 105 (pnl=+5) + // TradingView semantics: with process_orders_on_close=true a market + // strategy.entry fills at THIS bar's close, but the resulting position is + // NOT visible to strategy.position_size / strategy.position_avg_price until + // the NEXT bar's evaluation (the broker state updates between bars). So on + // the bar that places the entry the script still sees the pre-entry state. + // + // Bar 0: long entry placed; position not yet visible this bar (still flat). + CHECK(near(strat.pos_size[0], 0.0)); + CHECK(near(strat.pos_avg[0], 0.0)); + + // Bar 2: reversal entry placed; the long opened on bar 0 (visible since + // bar 1) is still the live position during this bar's script — the flip to + // short fills at bar-2 close and only becomes visible on bar 3. + CHECK(near(strat.pos_size[2], 1.0)); + CHECK(near(strat.pos_avg[2], 100.0)); + CHECK(near(strat.net_pnl[2], 0.0)); // long not closed yet from script POV + + // Bar 3: short now visible at avg 110, closed long realized +10. + CHECK(near(strat.pos_size[3], -1.0)); + CHECK(near(strat.pos_avg[3], 110.0)); + CHECK(near(strat.net_pnl[3], 10.0)); + + // Bar 4: short closed at 105 (pnl=+5). strategy.close executes immediately + // on close, so the flat state and realized +15 are visible this bar. CHECK(near(strat.pos_size[4], 0.0)); CHECK(near(strat.net_pnl[4], 15.0)); // 10 + 5 CHECK(strat.trade_count() == 2); } +// ---- 38b. process_orders_on_close: an exit gated on position visibility must +// NOT fire on the entry bar. TradingView does not expose a just-placed market +// entry through strategy.position_size until the next bar, so a regime/bias +// style `if strategy.position_size != 0 => strategy.close()` cannot close the +// position on the bar it was opened. Regression guard for the Quant-Synthesis +// [JOAT] same-bar-close family (engine previously immediate-filled POOC market +// entries and produced spurious zero-duration trades). + +class EntryBarCloseGuardStrategy : public BacktestEngine { +public: + int close_calls_on_entry_bar = 0; + EntryBarCloseGuardStrategy() { + initial_capital_ = 10000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0; + slippage_ = 0; + process_orders_on_close_ = true; + } + void on_bar(const Bar& bar) override { + (void)bar; + if (bar_index_ == 0) strategy_entry("L", true); + if (signed_position_size() != 0.0) { + if (bar_index_ == 0) ++close_calls_on_entry_bar; + strategy_close("L"); + } + } +}; + +static void test_pooc_exit_not_triggered_on_entry_bar() { + std::printf("test_pooc_exit_not_triggered_on_entry_bar\n"); + EntryBarCloseGuardStrategy strat; + Bar bars[] = { + {100, 105, 95, 100, 50, 60000}, // long entry placed; fills at close + {100, 110, 98, 108, 50, 120000}, // position now visible -> close here + {108, 112, 105, 110, 50, 180000}, + }; + strat.run(bars, 3); + + // The gated close must never fire on the entry bar. + CHECK(strat.close_calls_on_entry_bar == 0); + // Exactly one closed trade, not a zero-duration same-bar trade: entered at + // bar-0 close (100), closed at bar-1 close (108) once the position became + // visible. + CHECK(strat.trade_count() == 1); + CHECK(near(strat.get_trade(0).pnl, 8.0)); // long 100 -> 108 +} + // ---- 39. Commission impact on P&L class CommissionStrategy : public BacktestEngine { @@ -4103,6 +4165,7 @@ int main() { test_pyramid_avg_price(); test_win_loss_tracking(); test_position_reversal_state(); + test_pooc_exit_not_triggered_on_entry_bar(); test_commission_deducted(); test_slippage_applied(); test_qty_percent_of_equity();