diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 3a0fed2..79a07ff 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -693,6 +693,12 @@ class BacktestEngine { // it cannot rewrite otherwise matching trade tapes. bool margin_zero_cover_full_liquidation_ = false; int max_intraday_filled_orders_ = 0; // 0 = unlimited + // Default-off validation candidates for independently testable intraday- + // cap broker semantics. They ride the existing metadata channel + // so corpus execution remains byte-identical unless explicitly enabled. + bool intraday_cap_skip_noop_market_fills_ = false; + bool intraday_cap_defer_pooc_close_ = false; + bool intraday_cap_count_pooc_full_close_fills_ = false; bool close_entries_rule_any_ = false; // true = "ANY", false = "FIFO" (default) // Percentage of margin required to open a long/short position. Default // 100 = 1x leverage (no leverage). TradingView's strategy() takes these @@ -951,6 +957,15 @@ class BacktestEngine { int intraday_fill_count_ = 0; int intraday_day_ = -1; // day key (dayofmonth*100+month) for reset bool intraday_cap_hit_ = false; // latched once per chart-day; reset on day rollover + // State, not configuration: a POOC MARKET fill reached the cap at the + // signal close and its risk-generated flatten is due at the next broker + // boundary (the next ordinary bar open). + bool intraday_cap_deferred_close_pending_ = false; + // An ordinary POOC strategy.close plus one co-queued opposite MARKET is + // materialized as two engine operations even when TV reports one reversal + // fill. The close spends the quota immediately; this fresh order identity + // may inherit that already-spent slot if it survives every fill-time gate. + uint64_t intraday_cap_pooc_close_inheritor_incarnation_ = 0; // True iff the intraday cap is currently latched on the CURRENT bar's // chart-day. Performs a lazy day-rollover reset so callers outside the @@ -2531,6 +2546,18 @@ class BacktestEngine { margin_zero_cover_full_liquidation_ = std::isfinite(value) && value > 0.0; } + if (key == "intraday_cap_skip_noop_market_fills") { + intraday_cap_skip_noop_market_fills_ = + std::isfinite(value) && value > 0.0; + } + if (key == "intraday_cap_defer_pooc_close") { + intraday_cap_defer_pooc_close_ = + std::isfinite(value) && value > 0.0; + } + if (key == "intraday_cap_count_pooc_full_close_fills") { + intraday_cap_count_pooc_full_close_fills_ = + std::isfinite(value) && value > 0.0; + } // "qty_step" is the per-instrument lot increment used by the forced- // liquidation quantizer. Route it onto the dedicated member so the // codegen run(const Bar*, int) path (which never overwrites it) keeps diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 0f0ece1..5268fab 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1265,7 +1265,15 @@ void BacktestEngine::apply_filled_order_to_state( uint64_t& exit_closed_from_incarnation, bool& exit_closed_was_long, std::vector& filled_indices) { + const bool inherits_pooc_close_fill = + intraday_cap_count_pooc_full_close_fills_ + && order.incarnation != 0 + && order.incarnation + == intraday_cap_pooc_close_inheritor_incarnation_; auto decline_and_cancel = [&]() { + if (inherits_pooc_close_fill) { + intraday_cap_pooc_close_inheritor_incarnation_ = 0; + } invalidate_pending_flat_market_pair(order.created_seq); filled_indices.push_back(order_index); }; @@ -1745,6 +1753,27 @@ void BacktestEngine::apply_filled_order_to_state( } } + // A same-direction MARKET can reach the fill kernel while the live + // position is already at its pyramiding cap (a later-bar reissue, or a + // same-tick sibling after an earlier entry opened the position). The + // established dispatch consumes it but mutates no position + // (add_to_pyramid_market's cap branch); opt-in factor A consumes it here + // before risk-cap accounting so an attempt that never filled does not + // spend max_intraday_filled_orders quota. Classify against LIVE state at + // fill time so close-then-reentry and reversals remain real fills. + if (intraday_cap_skip_noop_market_fills_ + && max_intraday_filled_orders_ > 0 + && order.type == OrderType::MARKET + && position_side_ != PositionSide::FLAT) { + const PositionSide requested = + order.is_long ? PositionSide::LONG : PositionSide::SHORT; + if (position_side_ == requested + && position_entry_count_ >= pyramiding_) { + decline_and_cancel(); + return; + } + } + // Check max_intraday_filled_orders limit. // // TV's broker emulator (LATCH-TILL-DAY-ROLLOVER semantics): @@ -1780,14 +1809,25 @@ void BacktestEngine::apply_filled_order_to_state( intraday_fill_count_ = 0; intraday_cap_hit_ = false; // RESET LATCH on chart-day rollover } - if (intraday_cap_hit_) { + // A POOC close+opposite-entry reversal is split by the engine into a + // close operation followed by a MARKET operation. Factor C counted + // the close synchronously, before this order could be rejected or + // cancelled. Only the exact surviving incarnation may continue the + // same broker event without spending a second slot; all ordinary + // orders still obey the latch. Every pre-account admission failure + // above clears the inheritance while retaining the real close count. + if (intraday_cap_hit_ && !inherits_pooc_close_fill) { // Latched: drop this pending order and skip dispatch. // Removing from pending_orders_ matches TV's behaviour of // silently consuming/rejecting fills past the daily cap. decline_and_cancel(); return; } - intraday_fill_count_++; + if (inherits_pooc_close_fill) { + intraday_cap_pooc_close_inheritor_incarnation_ = 0; + } else { + intraday_fill_count_++; + } will_trigger_cap = (intraday_fill_count_ >= max_intraday_filled_orders_); } @@ -1943,6 +1983,17 @@ void BacktestEngine::apply_filled_order_to_state( || pyramid_entries_.size() != pyramid_lots_before_fill || trades_.size() != trades_before; + // The POOC close's quota identity can transfer only to the first broker + // operation that immediately continues that close as its designated + // reversal MARKET. If any other order actually fills first, broker order + // has moved on: expire the token before OCA effects and before this fill's + // cap close/latch. A matched-but-rejected or zero-effect attempt is not a + // broker fill and intentionally leaves the exact inheritor eligible. + if (primary_fill_applied && !inherits_pooc_close_fill + && intraday_cap_pooc_close_inheritor_incarnation_ != 0) { + intraday_cap_pooc_close_inheritor_incarnation_ = 0; + } + // Bounded POOC global-exit growth. Only MARKET adds that were already // pending when the one tracking EXIT was armed carry this relation bit. // Grow its ordinary finite reservation by the actual same-side quantity @@ -2164,6 +2215,26 @@ void BacktestEngine::apply_filled_order_to_state( // only on chart-day rollover (see top of this function). if (will_trigger_cap) { if (position_side_ != PositionSide::FLAT) { + // Opt-in factor B is deliberately narrow: an ordinary historical + // POOC run, no COOF/magnifier scheduler, and a MARKET created on + // this bar. TradingView accepts that entry at the signal close + // but emits the cap flatten at the next broker boundary. Latch + // immediately below; dispatch_bar performs the pending close at + // the next bar's open before any other broker work. + const bool defer_pooc_market_close = + intraday_cap_defer_pooc_close_ + && process_orders_on_close_ + && !calc_on_order_fills_ + && !bar_magnifier_enabled_ + && !stream_warmup_mode_ + && stream_phase_ == StreamPhase::IDLE + && order.type == OrderType::MARKET + && order.created_bar == bar_index_; + if (defer_pooc_market_close) { + intraday_cap_deferred_close_pending_ = true; + intraday_cap_hit_ = true; + return; + } // TV cap-close exit price empirics (probe 97 stop-entry + // cap composition): // diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 49939c7..e86028c 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -55,6 +55,35 @@ void BacktestEngine::dispatch_bar() { return; } + // A C-factor inheritance is same-ordinary-bar state. A candidate erased + // by replacement/OCA/cancel never reaches the fill kernel, so discard any + // stale identity before starting the next broker batch. + intraday_cap_pooc_close_inheritor_incarnation_ = 0; + + // Opt-in POOC intraday-cap candidate: the cap-triggering MARKET entry + // filled at the prior signal close, while the broker-generated flatten is + // due at this next broker boundary. Run it before resting orders and + // before the current bar's path is sampled so the exit is exactly at open. + if (intraday_cap_deferred_close_pending_) { + intraday_cap_deferred_close_pending_ = false; + if (position_side_ != PositionSide::FLAT) { + const size_t trades_before = trades_.size(); + const PositionSide side_before = position_side_; + const double qty_before = position_qty_; + execute_market_exit(current_bar_.open); + if (position_side_ != side_before + || std::abs(position_qty_ - qty_before) > kQtyEpsilon + || trades_.size() != trades_before) { + ++broker_fill_event_seq_; + } + for (size_t ti = trades_before; ti < trades_.size(); ++ti) { + trades_[ti].exit_comment = + "Close Position (Max number of filled orders in one day)"; + trades_[ti].exit_id = ""; + } + } + } + // Advance native source-series history before strategy logic so // get_input_source()'s returned series is current for this bar. Covers // the simple run() loop, run_simple_bar_loop, and the no-magnifier @@ -67,6 +96,7 @@ void BacktestEngine::dispatch_bar() { invoke_chart_on_bar(current_bar_); // step 3: strategy logic flush_same_bar_close(); // step 3b: surviving strategy.close fill process_pending_orders(current_bar_); // step 4: new market orders + intraday_cap_pooc_close_inheritor_incarnation_ = 0; } else { process_pending_orders(current_bar_); update_per_trade_extremes(); @@ -513,6 +543,8 @@ void BacktestEngine::reset_run_state() { intraday_day_ = -1; intraday_cap_hit_ = false; intraday_fill_count_ = 0; + intraday_cap_deferred_close_pending_ = false; + intraday_cap_pooc_close_inheritor_incarnation_ = 0; broker_fill_event_seq_ = 0; coof_scheduler_active_ = false; coof_fill_recalc_active_ = false; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index efafdcb..08e57ea 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -789,6 +789,62 @@ void BacktestEngine::flush_same_bar_close() { || trades_.size() != trades_before; if (close_filled) { ++broker_fill_event_seq_; + // Default-off follow-up to the no-op MARKET candidate: this broker + // fill bypasses apply_filled_order_to_state, but TradingView's + // max_intraday_filled_orders counts filled close orders too. Without + // this accounting, removing phantom no-op fills merely unmasks the + // opposite under-count and can admit an extra late-day trade cycle. + // Scope to the ordinary POOC same-bar batch exercised by Regime; other + // immediate/COOF close variants remain unchanged pending their own + // oracle. + if (intraday_cap_count_pooc_full_close_fills_ + && process_orders_on_close_ + && !calc_on_order_fills_ + && !coof_scheduler_active_ + && !bar_magnifier_enabled_ + && !stream_warmup_mode_ + && stream_phase_ == StreamPhase::IDLE + && !close_entries_rule_any_ + && closes_full_position + && max_intraday_filled_orders_ > 0) { + // Count the close now, never from the mere presence of a queued + // opposite order. If one co-queued opposite MARKET survives to + // its fill-time admission point, the earliest broker-order + // incarnation inherits this already-spent slot. A rejection, + // cancellation, replacement, or later no-op therefore cannot + // erase the real close fill; the marker simply expires. + const PendingOrder* inheritor = nullptr; + for (const PendingOrder& pending : pending_orders_) { + if (pending.type != OrderType::MARKET + || pending.created_bar != bar_index_ + || pending.is_long + == (side_before == PositionSide::LONG)) { + continue; + } + if (inheritor == nullptr + || pending.created_seq < inheritor->created_seq) { + inheritor = &pending; + } + } + intraday_cap_pooc_close_inheritor_incarnation_ = + inheritor == nullptr ? 0 : inheritor->incarnation; + + BarTime bt = _decompose_bar_time_chart_tz(); + const int cur_day = bt.dayofmonth * 100 + bt.month; + if (cur_day != intraday_day_) { + intraday_day_ = cur_day; + intraday_fill_count_ = 0; + intraday_cap_hit_ = false; + } + if (!intraday_cap_hit_) { + ++intraday_fill_count_; + if (intraday_fill_count_ >= max_intraday_filled_orders_) { + // The close already left the position flat, so no + // synthetic risk close is needed; only latch the day. + intraday_cap_hit_ = true; + } + } + } if (coof_scheduler_active_ && coof_direct_fill_events_remaining_ > 0) { --coof_direct_fill_events_remaining_; } diff --git a/tests/test_intraday_cap_auto_close.cpp b/tests/test_intraday_cap_auto_close.cpp index 135c9a3..1957d0a 100644 --- a/tests/test_intraday_cap_auto_close.cpp +++ b/tests/test_intraday_cap_auto_close.cpp @@ -212,11 +212,783 @@ void test_cap_disabled_does_not_inject_auto_close() { CHECK(std::fabs(strat.get_signed_position_size() - 3.0) < 1e-9); } +// A no-op same-direction MARKET attempt at pyramiding=0 is not a broker fill +// in TradingView. It therefore must not consume max_intraday_filled_orders. +// Run the Regime source shape in both directions: a later-bar signal reissues +// the same direction while the first position remains live. With cap=2 the +// real first fill is below the cap, while counting the later no-op as fill #2 +// would spuriously flatten and latch. +void test_noop_market_attempt_does_not_consume_cap(bool is_long) { + std::printf("test_noop_market_attempt_does_not_consume_cap(%s)\n", + is_long ? "long" : "short"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : is_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 2; + set_syminfo_metadata("intraday_cap_skip_noop_market_fills", 1.0); + } + bool is_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("E", is_long); + } else if (bar_index_ == 1) { + strategy_entry("REDUNDANT", is_long); + } + } + double get_signed_position_size() const { return signed_position_size(); } + }; + + Strat strat(is_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC}, + {101, 103, 99, 102, 50, kT0_UTC + k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.trade_count() == 0); + CHECK(std::fabs(strat.get_signed_position_size() + - (is_long ? 1.0 : -1.0)) < 1e-9); +} + +// A POOC MARKET entry that reaches the intraday cap is accepted at the signal +// close, but TV schedules the risk-generated flatten for the next broker +// boundary. For ordinary bars that boundary is the next bar's open. +void test_pooc_cap_close_defers_to_next_open(bool is_long) { + std::printf("test_pooc_cap_close_defers_to_next_open(%s)\n", + is_long ? "long" : "short"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : is_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 1; + set_syminfo_metadata("intraday_cap_defer_pooc_close", 1.0); + } + bool is_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("E", is_long); + } + }; + + Strat strat(is_long); + Bar bars[] = { + {100, 120, 80, is_long ? 110.0 : 90.0, 50, + kT0_UTC + 0 * k15m_ms}, + {is_long ? 111.0 : 89.0, 115, 85, 100, 50, + kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + const std::string kCapMsg = + "Close Position (Max number of filled orders in one day)"; + CHECK(strat.trade_count() == 1); + if (strat.trade_count() == 1) { + const Trade& trade = strat.get_trade(0); + CHECK(trade.entry_time == bars[0].timestamp); + CHECK(trade.exit_time == bars[1].timestamp); + CHECK(std::fabs(trade.exit_price - bars[1].open) < 1e-9); + CHECK(trade.exit_comment == kCapMsg); + CHECK(trade.exit_id.empty()); + } +} + +// Fill-time role controls for factor A. A same-tick close must make the +// following entry a real opening fill, and an opposite entry must remain a +// real reversal. Neither may be mistaken for a same-direction no-op merely +// because the order was created while a position existed. +void test_noop_filter_preserves_same_tick_close_then_reentry(bool is_long) { + std::printf("test_noop_filter_preserves_same_tick_close_then_reentry(%s)\n", + is_long ? "long" : "short"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : is_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 10; + set_syminfo_metadata("intraday_cap_skip_noop_market_fills", 1.0); + } + bool is_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("E", is_long); + } else if (bar_index_ == 1) { + strategy_close("E", "", std::nan(""), std::nan(""), + /*immediately=*/true); + strategy_entry("E2", is_long); + } + } + double get_signed_position_size() const { return signed_position_size(); } + }; + + Strat strat(is_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.get_signed_position_size() + - (is_long ? 1.0 : -1.0)) < 1e-9); +} + +void test_noop_filter_preserves_same_tick_reversal(bool starts_long) { + std::printf("test_noop_filter_preserves_same_tick_reversal(%s)\n", + starts_long ? "long-to-short" : "short-to-long"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : starts_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 10; + set_syminfo_metadata("intraday_cap_skip_noop_market_fills", 1.0); + } + bool starts_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("FIRST", starts_long); + } else if (bar_index_ == 1) { + strategy_entry("REVERSE", !starts_long); + } + } + double get_signed_position_size() const { return signed_position_size(); } + }; + + Strat strat(starts_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.get_signed_position_size() + - (starts_long ? -1.0 : 1.0)) < 1e-9); +} + +// Removing phantom no-op fills exposes the complementary POOC close path: +// strategy.close(id) is a real broker fill even though the same-bar batch does +// not pass through apply_filled_order_to_state. When it is fill #N it must +// latch the day and block a later entry. +void test_pooc_strategy_close_consumes_cap(bool is_long) { + std::printf("test_pooc_strategy_close_consumes_cap(%s)\n", + is_long ? "long" : "short"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : is_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 2; + set_syminfo_metadata("intraday_cap_skip_noop_market_fills", 1.0); + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + bool is_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("E", is_long); + } else if (bar_index_ == 1) { + strategy_close("E"); + } else if (bar_index_ == 2) { + strategy_entry("LATE", is_long); + } + } + double get_signed_position_size() const { return signed_position_size(); } + }; + + Strat strat(is_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + {102, 104, 100, 103, 50, kT0_UTC + 2 * k15m_ms}, + }; + strat.run(bars, 3); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.get_signed_position_size()) < 1e-9); +} + +// A close plus opposite MARKET entry on the same POOC tick is one TV reversal +// fill, even though the engine scheduler materializes a close before the entry. +// Counting both would latch on the scaffolding close and drop the real Nth fill. +void test_pooc_close_coqueued_with_reversal_counts_once(bool starts_long) { + std::printf("test_pooc_close_coqueued_with_reversal_counts_once(%s)\n", + starts_long ? "long-to-short" : "short-to-long"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : starts_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 2; + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + bool starts_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("FIRST", starts_long); + } else if (bar_index_ == 1) { + strategy_entry("REVERSE", !starts_long); + strategy_close("FIRST"); + } + } + double get_signed_position_size() const { return signed_position_size(); } + }; + + Strat strat(starts_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.trade_count() == 2); + CHECK(std::fabs(strat.get_signed_position_size()) < 1e-9); + if (strat.trade_count() == 2) { + CHECK(strat.get_trade(1).exit_comment == + "Close Position (Max number of filled orders in one day)"); + } +} + +// A queued opposite MARKET is not proof that the strategy.close disappeared +// into a successful reversal. The entry can survive placement and still fail +// the fill-time direction gate after the close leaves the engine flat. The +// real close must retain its quota slot and latch cap=2, blocking LATE. +void test_pooc_close_count_survives_rejected_reversal(bool starts_long) { + std::printf("test_pooc_close_count_survives_rejected_reversal(%s)\n", + starts_long ? "long-held" : "short-held"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : starts_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 2; + risk_direction_ = starts_long + ? RiskDirection::LONG_ONLY : RiskDirection::SHORT_ONLY; + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + bool starts_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("FIRST", starts_long); + } else if (bar_index_ == 1) { + strategy_entry("REJECTED", !starts_long); + strategy_close("FIRST"); + } else if (bar_index_ == 2) { + strategy_entry("LATE", starts_long); + } + } + double position_size() const { return signed_position_size(); } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat(starts_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + {102, 104, 100, 103, 50, kT0_UTC + 2 * k15m_ms}, + }; + strat.run(bars, 3); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(strat.fill_count() == 2); + CHECK(strat.cap_hit()); +} + +// The same close quota survives when an earlier RAW market fill OCA-cancels +// the designated opposite MARKET after flush selected its exact incarnation. +// This is the cancellation shape a mere pending-order proxy gets wrong. +void test_pooc_close_count_survives_cancelled_reversal(bool starts_long) { + std::printf("test_pooc_close_count_survives_cancelled_reversal(%s)\n", + starts_long ? "long-held" : "short-held"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : starts_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 4; + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + bool starts_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("FIRST", starts_long); + } else if (bar_index_ == 1) { + strategy_order("CANCELER", !starts_long, 1.0, + std::nan(""), std::nan(""), "PAIR", 1); + strategy_entry("CANCELLED", !starts_long, + std::nan(""), std::nan(""), std::nan(""), + "", "PAIR", 1); + strategy_close("FIRST"); + } + } + double position_size() const { return signed_position_size(); } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat(starts_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size() + - (starts_long ? -1.0 : 1.0)) < 1e-9); + CHECK(strat.fill_count() == 3); + CHECK(!strat.cap_hit()); +} + +// The designated opposite MARKET can also become a same-direction no-op: an +// earlier RAW market order opens that side first. C counts the close at once; +// A later declines the redundant MARKET without rolling the close count back. +void test_pooc_close_count_survives_noop_reversal(bool starts_long) { + std::printf("test_pooc_close_count_survives_noop_reversal(%s)\n", + starts_long ? "long-to-short" : "short-to-long"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : starts_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 4; + set_syminfo_metadata("intraday_cap_skip_noop_market_fills", 1.0); + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + bool starts_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("FIRST", starts_long); + } else if (bar_index_ == 1) { + strategy_order("EARLY", !starts_long, 1.0); + strategy_entry("REDUNDANT", !starts_long); + strategy_close("FIRST"); + } + } + double position_size() const { return signed_position_size(); } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat(starts_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size() + - (starts_long ? -1.0 : 1.0)) < 1e-9); + CHECK(strat.fill_count() == 3); + CHECK(!strat.cap_hit()); +} + +// Cap-boundary version of the RAW-before-MARKET sequence. EARLY is a distinct +// fill and reaches cap=3, so it expires the saved inheritance before its cap +// close/latch. INHERITOR must then obey that latch; otherwise it opens and is +// cap-closed a second time on the same day. +void test_intervening_fill_expires_pooc_close_inheritance(bool starts_long) { + std::printf("test_intervening_fill_expires_pooc_close_inheritance(%s)\n", + starts_long ? "long-to-short" : "short-to-long"); + + class Strat : public BacktestEngine { + public: + explicit Strat(bool direction) : starts_long(direction) { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 3; + set_syminfo_metadata("intraday_cap_skip_noop_market_fills", 1.0); + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + bool starts_long; + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("FIRST", starts_long); + } else if (bar_index_ == 1) { + strategy_order("EARLY", !starts_long, 1.0); + strategy_entry("INHERITOR", !starts_long); + strategy_close("FIRST"); + } + } + double position_size() const { return signed_position_size(); } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat(starts_long); + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + int cap_closes = 0; + bool saw_inheritor = false; + for (int i = 0; i < strat.trade_count(); ++i) { + const Trade& trade = strat.get_trade(i); + if (trade.exit_comment == + "Close Position (Max number of filled orders in one day)") { + ++cap_closes; + } + if (trade.entry_id == "INHERITOR") saw_inheritor = true; + } + CHECK(strat.trade_count() == 2); + CHECK(cap_closes == 1); + CHECK(!saw_inheritor); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(strat.fill_count() == 3); + CHECK(strat.cap_hit()); +} + +// C has only an ordinary non-magnified oracle. Enabling its metadata must not +// alter a magnifier run until the lower-TF close/reversal contract is probed. +void test_pooc_close_count_candidate_excludes_magnifier() { + std::printf("test_pooc_close_count_candidate_excludes_magnifier\n"); + + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 2; + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("FIRST", true); + if (bar_index_ == 1) strategy_close("FIRST"); + } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat; + Bar lower[] = { + {100, 101, 99, 100, 50, kT0_UTC + 0 * 60'000LL}, + {100, 102, 99, 101, 50, kT0_UTC + 1 * 60'000LL}, + {101, 103, 100, 102, 50, kT0_UTC + 2 * 60'000LL}, + {102, 104, 101, 103, 50, kT0_UTC + 3 * 60'000LL}, + }; + strat.run(lower, 4, "1", "2", /*bar_magnifier=*/true, + /*magnifier_samples=*/4, MagnifierDistribution::ENDPOINTS); + + CHECK(strat.last_error().empty()); + CHECK(strat.trade_count() == 1); + CHECK(strat.fill_count() == 1); + CHECK(!strat.cap_hit()); +} + +void test_pooc_deferred_cap_candidate_excludes_magnifier() { + std::printf("test_pooc_deferred_cap_candidate_excludes_magnifier\n"); + + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 1; + set_syminfo_metadata("intraday_cap_defer_pooc_close", 1.0); + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("FIRST", true); + } + double position_size() const { return signed_position_size(); } + bool deferred_close_pending() const { + return intraday_cap_deferred_close_pending_; + } + }; + + Strat strat; + Bar lower[] = { + {100, 101, 99, 100, 50, kT0_UTC + 0 * 60'000LL}, + {100, 102, 99, 101, 50, kT0_UTC + 1 * 60'000LL}, + }; + strat.run(lower, 2, "1", "2", /*bar_magnifier=*/true, + /*magnifier_samples=*/4, MagnifierDistribution::ENDPOINTS); + + CHECK(strat.last_error().empty()); + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(!strat.deferred_close_pending()); +} + +void test_pooc_deferred_cap_candidate_excludes_coof() { + std::printf("test_pooc_deferred_cap_candidate_excludes_coof\n"); + + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + calc_on_order_fills_ = true; + max_intraday_filled_orders_ = 1; + set_syminfo_metadata("intraday_cap_defer_pooc_close", 1.0); + } + void on_bar(const Bar&) override { + if (bar_index_ == 0 && std::fabs(signed_position_size()) < 1e-9) { + strategy_entry("FIRST", true); + } + } + double position_size() const { return signed_position_size(); } + bool deferred_close_pending() const { + return intraday_cap_deferred_close_pending_; + } + }; + + Strat strat; + Bar bars[] = {{100, 102, 98, 101, 50, kT0_UTC}}; + strat.run(bars, 1); + + CHECK(strat.last_error().empty()); + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(!strat.deferred_close_pending()); +} + +void test_pooc_close_count_candidate_excludes_coof() { + std::printf("test_pooc_close_count_candidate_excludes_coof\n"); + + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + calc_on_order_fills_ = true; + max_intraday_filled_orders_ = 3; + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + void on_bar(const Bar&) override { + if (bar_index_ == 0 && std::fabs(signed_position_size()) < 1e-9) { + strategy_entry("FIRST", true); + } else if (bar_index_ == 1 + && std::fabs(signed_position_size()) > 1e-9) { + strategy_close("FIRST"); + } + } + double position_size() const { return signed_position_size(); } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat; + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.last_error().empty()); + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(strat.fill_count() == 1); + CHECK(!strat.cap_hit()); +} + +void test_pooc_close_count_candidate_excludes_any_mode() { + std::printf("test_pooc_close_count_candidate_excludes_any_mode\n"); + + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + close_entries_rule_any_ = true; + max_intraday_filled_orders_ = 3; + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("FIRST", true); + if (bar_index_ == 1) strategy_close("FIRST"); + } + double position_size() const { return signed_position_size(); } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat; + Bar bars[] = { + {100, 102, 98, 101, 50, kT0_UTC + 0 * k15m_ms}, + {101, 103, 99, 102, 50, kT0_UTC + 1 * k15m_ms}, + }; + strat.run(bars, 2); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(strat.fill_count() == 1); + CHECK(!strat.cap_hit()); +} + +void test_pooc_close_count_candidate_excludes_stream_realtime() { + std::printf("test_pooc_close_count_candidate_excludes_stream_realtime\n"); + + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 3; + set_syminfo_metadata("intraday_cap_count_pooc_full_close_fills", 1.0); + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("FIRST", true); + if (bar_index_ == 1) strategy_close("FIRST"); + } + double position_size() const { return signed_position_size(); } + int fill_count() const { return intraday_fill_count_; } + bool cap_hit() const { return intraday_cap_hit_; } + }; + + Strat strat; + Bar warmup[] = {{100, 102, 98, 101, 50, kT0_UTC}}; + CHECK(strat.stream_begin(warmup, 1, "1", "1")); + CHECK(std::fabs(strat.position_size() - 1.0) < 1e-9); + TradeTick tick{kT0_UTC + 60'001LL, 1, 102.0, 1.0}; + CHECK(strat.stream_push_tick(tick)); + CHECK(strat.stream_advance_time(kT0_UTC + 120'000LL)); + + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(strat.fill_count() == 1); + CHECK(!strat.cap_hit()); + CHECK(strat.stream_end(false)); +} + +// B's next-ordinary-bar-open model cannot cross stream_begin's historical to +// realtime boundary: realtime ticks do not route through dispatch_bar(). The +// warmup therefore stays on established immediate-cap-close semantics. +void test_pooc_deferred_cap_candidate_excludes_stream_warmup() { + std::printf("test_pooc_deferred_cap_candidate_excludes_stream_warmup\n"); + + class Strat : public BacktestEngine { + public: + Strat() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + pyramiding_ = 0; + process_orders_on_close_ = true; + max_intraday_filled_orders_ = 1; + set_syminfo_metadata("intraday_cap_defer_pooc_close", 1.0); + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("FIRST", true); + } + double position_size() const { return signed_position_size(); } + bool deferred_close_pending() const { + return intraday_cap_deferred_close_pending_; + } + }; + + Strat strat; + Bar warmup[] = {{100, 102, 98, 101, 50, kT0_UTC}}; + CHECK(strat.stream_begin(warmup, 1, "1", "1")); + CHECK(strat.trade_count() == 1); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(!strat.deferred_close_pending()); + + TradeTick next_tick{kT0_UTC + 60'001LL, 1, 102.0, 1.0}; + CHECK(strat.stream_push_tick(next_tick)); + CHECK(std::fabs(strat.position_size()) < 1e-9); + CHECK(strat.trade_count() == 1); + CHECK(strat.stream_end(false)); +} + } // namespace int main() { test_cap_latches_until_day_rollover(); test_cap_disabled_does_not_inject_auto_close(); + test_noop_market_attempt_does_not_consume_cap(true); + test_noop_market_attempt_does_not_consume_cap(false); + test_pooc_cap_close_defers_to_next_open(true); + test_pooc_cap_close_defers_to_next_open(false); + test_noop_filter_preserves_same_tick_close_then_reentry(true); + test_noop_filter_preserves_same_tick_close_then_reentry(false); + test_noop_filter_preserves_same_tick_reversal(true); + test_noop_filter_preserves_same_tick_reversal(false); + test_pooc_strategy_close_consumes_cap(true); + test_pooc_strategy_close_consumes_cap(false); + test_pooc_close_coqueued_with_reversal_counts_once(true); + test_pooc_close_coqueued_with_reversal_counts_once(false); + test_pooc_close_count_survives_rejected_reversal(true); + test_pooc_close_count_survives_rejected_reversal(false); + test_pooc_close_count_survives_cancelled_reversal(true); + test_pooc_close_count_survives_cancelled_reversal(false); + test_pooc_close_count_survives_noop_reversal(true); + test_pooc_close_count_survives_noop_reversal(false); + test_intervening_fill_expires_pooc_close_inheritance(true); + test_intervening_fill_expires_pooc_close_inheritance(false); + test_pooc_close_count_candidate_excludes_magnifier(); + test_pooc_deferred_cap_candidate_excludes_magnifier(); + test_pooc_deferred_cap_candidate_excludes_coof(); + test_pooc_close_count_candidate_excludes_coof(); + test_pooc_close_count_candidate_excludes_any_mode(); + test_pooc_close_count_candidate_excludes_stream_realtime(); + test_pooc_deferred_cap_candidate_excludes_stream_warmup(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; }