diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 206eed6..678da3b 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -53,6 +53,12 @@ struct PyramidEntry { // the full bar). bool skip_entry_bar_high = false; bool skip_entry_bar_low = false; + // KI-62: this slice opened as a same-direction MARKET pyramid add (not the + // base open, not a priced entry). When a from_entry priced bracket exit + // fills on this add's OWN entry bar and after it in TV's open-tick fill + // sequence, the exit covers (scratches) the add dur-0. Gated by + // entry_bar_index == bar_index_ so prior-bar slices are never covered. + bool market_pyramid_add = false; }; struct Trade { @@ -1883,6 +1889,12 @@ class BacktestEngine { void execute_partial_exit(double fill_price, double qty_percent); void execute_partial_exit_by_entry(double fill_price, const std::string& from_entry); void execute_partial_exit_by_entry_percent(double fill_price, const std::string& from_entry, double qty_percent); + // KI-62: scratch (close dur-0) any same-bar same-id MARKET pyramid-add + // slices still open after a from_entry priced bracket exit fills — TV's + // open-tick fill sequence covered them. Targets only flagged same-bar add + // slices (never the frozen pre-add lot, never a prior-bar slice). Returns + // the qty scratched (0 = no collision → strict no-op). + double cover_samebar_market_adds_on_exit(const PendingOrder& order, double fill_price); void cancel_oca_group(const std::string& oca_name, const std::string& exclude_id); // Pine v6 oca.reduce: when one sibling fills qty Q, reduce remaining // siblings' qty by Q. Siblings whose qty becomes <= 0 are cancelled. diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 922f4f9..9167681 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -646,8 +646,55 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { bool b_exit_style = order_is_exit_style(b, position_side_); bool a_entry_same = is_entry_same_as_current_position(a); bool b_entry_same = is_entry_same_as_current_position(b); - if (a_exit_style && b_entry_same) return true; - if (b_exit_style && a_entry_same) return false; + // KI-62: a from_entry PRICED bracket exit that gaps through a leg + // at the open, paired with its OWN same-id MARKET pyramid add (also + // filling at the open). TV's open-tick fill priority is + // buy-market-like(1) → sell-market-like(2) → gapped-through + // limit(3); the exit covers (scratches) the add iff the add fills + // at-or-before the exit. Order the pair by that priority instead of + // the blanket exit-before-same-dir-entry rule (which keeps every + // add → uniform-KEEP). Returns 1 = exit first, 0 = add first, + // -1 = not this collision (fall through to the blanket rule). + auto samebar_add_exit_first = [&](const PendingOrder& ex, + const PendingOrder& add) -> int { + if (ex.type != OrderType::EXIT) return -1; + if (ex.from_entry.empty() || ex.from_entry != add.id) return -1; + // The add must be a pure market order (no priced/trail leg). + if (!std::isnan(add.stop_price) || !std::isnan(add.limit_price) + || !std::isnan(add.trail_points) + || !std::isnan(add.trail_price)) { + return -1; + } + bool ex_stop = !std::isnan(ex.stop_price); + bool ex_limit = !std::isnan(ex.limit_price); + bool ex_trail = !std::isnan(ex.trail_points) + || !std::isnan(ex.trail_price); + if (ex_trail || (!ex_stop && !ex_limit)) return -1; + int exit_prio; + if (position_side_ == PositionSide::LONG) { + if (ex_stop && bar.open <= ex.stop_price) exit_prio = 2; + else if (ex_limit && bar.open >= ex.limit_price) exit_prio = 3; + else return -1; // not gapped through a leg at the open + } else { // SHORT + if (ex_stop && bar.open >= ex.stop_price) exit_prio = 1; + else if (ex_limit && bar.open <= ex.limit_price) exit_prio = 3; + else return -1; + } + int add_prio = add.is_long ? 1 : 2; + // The exit sorts first only when it strictly precedes the add; + // add_prio <= exit_prio ⇒ add fills first ⇒ exit scratches it. + return (exit_prio < add_prio) ? 1 : 0; + }; + if (a_exit_style && b_entry_same) { + int d = samebar_add_exit_first(a, b); + if (d != -1) return d == 1; + return true; + } + if (b_exit_style && a_entry_same) { + int d = samebar_add_exit_first(b, a); + if (d != -1) return d == 0; + return false; + } // TradingView empirically processes a same-bar full market // exit BEFORE an opposite-direction priced (stop/limit) entry, // even when the priced entry gaps through the open and would @@ -1669,10 +1716,25 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric consumed_partial_exit_ids_.insert(order.id); } + // KI-62: the normal close above drained only the frozen pre-add reserve + // (FIFO, oldest lot). A same-id MARKET add that filled earlier THIS bar + // (ahead of the exit in TV's open-tick fill sequence) is still open; TV + // covers it — scratch it dur-0 at the exit's fill price. A strict no-op + // when no such add filled (the KEEP cell: the exit fills first, so the add + // is not yet open here; and non-collision shapes flag no add slice). + double scratched = cover_samebar_market_adds_on_exit(order, fill_price); + // Full exit that closed the position: pending SAME-direction entries // placed on a different on_bar are cancelled for the rest of this - // bar (TV's same-direction cancellation rule). - if (!is_partial && position_side_ == PositionSide::FLAT) { + // bar (TV's same-direction cancellation rule). A same-bar-add scratch that + // flattens the position is such a full close (the exit covered lot + add), + // so key on the post-scratch FLAT state rather than the exit's own + // pre-scratch is_partial (which reads true when the add filled first). + // Byte-identical pre-fix: a genuine partial exit never flattens + // (reserved < position), so !is_partial && FLAT == FLAT there. + (void)scratched; + if (position_side_ == PositionSide::FLAT + && side_before_exit != PositionSide::FLAT) { exit_closed_from_bar = order.created_bar; exit_closed_was_long = (side_before_exit == PositionSide::LONG); } @@ -1812,6 +1874,9 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price position_entry_count_++; trail_best_price_ = fill_price; pyramid_entries_.push_back({fill_price, current_bar_.timestamp, new_qty, order.id, bar_index_}); + // KI-62: flag same-direction MARKET adds (strategy.order path) so a + // same-bar from_entry bracket exit can scratch them dur-0. + pyramid_entries_.back().market_pyramid_add = !is_priced_entry; id_unclosed_qty_[order.id] += new_qty; if (is_priced_entry) { set_entry_fill_excursion_masks(pyramid_entries_.back(), current_bar_, fill_price); diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 0f32008..bb01f47 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -179,7 +179,8 @@ double BacktestEngine::fifo_drain(const std::string* from_entry, double qty_limi pe.max_runup * keep_scale, pe.max_drawdown * keep_scale, pe.skip_entry_bar_high, - pe.skip_entry_bar_low}); + pe.skip_entry_bar_low, + pe.market_pyramid_add}); } } @@ -272,6 +273,54 @@ void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price, } +// KI-62: after a from_entry PRICED bracket exit fills, scratch (close dur-0) +// any same-bar same-id MARKET pyramid-add slice still open — it filled earlier +// this bar, ahead of the exit in TV's open-tick fill sequence, so TV's exit +// covers it. Targets ONLY flagged same-bar (entry_bar_index == bar_index_) +// market-add slices of this from_entry: the frozen pre-add lot was already +// drained by the normal close, and prior-bar slices (entry_bar_index < +// bar_index_) are never touched — so multi-bar pyramids stay untouched. A +// strict no-op when no such slice exists (the KEEP cell fills the exit first, +// so the add is not yet open; non-collision shapes flag no add). Emits each +// covered slice as its own dur-0 trade (entry at the add's fill price, exit at +// this exit's fill price), matching TV's per-pyramid scratch reporting. +double BacktestEngine::cover_samebar_market_adds_on_exit(const PendingOrder& order, + double fill_price) { + if (order.from_entry.empty()) return 0.0; + if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return 0.0; + // Scope to a PRICED bracket (stop/limit/trail). A plain market close / + // close_all already flattens the whole position through its own path. + bool priced_bracket = !std::isnan(order.stop_price) + || !std::isnan(order.limit_price) + || !std::isnan(order.trail_points) + || !std::isnan(order.trail_price); + if (!priced_bracket) return 0.0; + + bool is_buy = (position_side_ == PositionSide::SHORT); + double slipped = apply_fill_slippage(fill_price, is_buy); + bool was_long = (position_side_ == PositionSide::LONG); + + std::vector remaining; + remaining.reserve(pyramid_entries_.size()); + double closed = 0.0; + for (auto& pe : pyramid_entries_) { + if (pe.market_pyramid_add + && pe.entry_bar_index == bar_index_ + && pe.entry_id == order.from_entry) { + emit_close_trade(pe, pe.qty, slipped, was_long); + closed += pe.qty; + } else { + remaining.push_back(pe); + } + } + if (closed <= kQtyEpsilon) return 0.0; // nothing covered + pyramid_entries_ = std::move(remaining); + position_qty_ -= closed; + settle_position_after_partial_exit(); + return closed; +} + + // Internal helper: cancel OCA group members (except the one that just filled) void BacktestEngine::cancel_oca_group(const std::string& oca_name, const std::string& exclude_id) { if (oca_name.empty()) return; @@ -705,6 +754,9 @@ void BacktestEngine::add_to_pyramid_market(const std::string& id, bool is_long, position_entry_count_++; trail_best_price_ = fill_price; pyramid_entries_.push_back({fill_price, current_bar_.timestamp, new_qty, id, bar_index_}); + // KI-62: only a same-direction MARKET add is scratched by a same-bar + // from_entry bracket exit; a priced pyramid add is not this collision. + pyramid_entries_.back().market_pyramid_add = !is_priced_entry; id_unclosed_qty_[id] += new_qty; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 54e4023..b619bcf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ set(TEST_SOURCES test_full_close_while_pyramiding test_deferred_flip_carry_close_only test_close_all_coqueued_entry + test_same_bar_add_exit_coverage test_declined_reversal_close_leg test_dual_entry_placement_sizing test_lower_tf_parse_extra diff --git a/tests/test_same_bar_add_exit_coverage.cpp b/tests/test_same_bar_add_exit_coverage.cpp new file mode 100644 index 0000000..cb8076b --- /dev/null +++ b/tests/test_same_bar_add_exit_coverage.cpp @@ -0,0 +1,291 @@ +/* + * test_same_bar_add_exit_coverage.cpp — KI-62 keep-vs-scratch (probe-pinned). + * + * When a pre-queued SAME-ID MARKET pyramid add and a from_entry PRICED bracket + * exit both fill on the same bar, TradingView's open-tick fill priority is + * buy-market-like (market + triggered buy stops) + * -> sell-market-like (market + triggered sell stops) + * -> gapped-through limit orders (both sides, at the open, last). + * The exit covers the add (the add scratches dur-0) iff the add's fill + * precedes-or-ties the exit's fill in that sequence. Intrabar exit fills + * (open inside the bracket, level hit AFTER the open) process after every + * open fill, so they always cover an open-filled add. + * + * Mapping the collision (add is always a market order; the exit closes the + * position so it is the opposite side): + * add prio = LONG add -> buy(1), SHORT add -> sell(2) + * exit prio = gapped STOP -> (SHORT pos buy-stop = 1, LONG pos sell-stop = 2) + * gapped LIMIT -> 3 (either side) + * intrabar -> 4 (after all open fills) + * scratch iff add_prio <= exit_prio. + * + * LONG + gap-stop : add buy(1) <= sell-stop(2) -> SCRATCH (SCR-OPEN) + * LONG + gap-limit: add buy(1) <= sell-limit(3) -> SCRATCH (SCR-OPEN) + * SHORT + gap-stop : add sell(2) vs buy-stop(1) -> KEEP (exit sized pre-add) + * SHORT + gap-limit: add sell(2) <= buy-limit(3) -> SCRATCH (SCR-OPEN) + * intrabar (either): add(open) < exit(4) -> SCRATCH (SCR-INTRA, pnl!=0) + * + * The scratch materializes as a dur-0 trade for the add slice: entry at the + * add's fill price, exit at the exit's fill price (== the open for gapped + * scratches -> pnl 0; == the bracket level for intrabar -> pnl != 0). + * + * The current engine (b6e4e35) is uniform-KEEP: the from_entry bracket freezes + * its reserved qty to the pre-add lot at arm time and always fills before the + * same-dir add at the open, so every add carries (0 dur-0). The scratch cells + * below FAIL pre-fix (position carries, 1 trade) and PASS post-fix (flat, + * 2 trades); the KEEP / control / pyr=1 guards pass PRE and POST. + */ + +#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-6) { + return std::fabs(a - b) <= tol; +} + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk(double o, double h, double l, double c, int64_t ts) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1000.0; b.timestamp = ts; + return b; +} + +// Common probe base: fixed 1-lot sizing, no slippage/commission, tick 0.01. +class ProbeBase : public BacktestEngine { +public: + explicit ProbeBase(int pyr) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = pyr; + syminfo_mintick_ = 0.01; + } + double pos_size() const { return signed_position_size(); } + int n_trades() const { return (int)trades_.size(); } + // Last emitted trade (the add scratch in a scratch cell). + const Trade& last_trade() const { return trades_.back(); } + bool last_is_dur0() const { + const Trade& t = trades_.back(); + return t.entry_bar_index == t.exit_bar_index; + } + double last_pnl() const { return trades_.back().pnl; } +}; + +// One collision fixture. `is_long` picks the position side; `limit`/`stop` +// arm the from_entry bracket; the bar list carries the collision geometry. +// bar0 queues base "P"; bar1 fills it @100 and (on_bar) arms the bracket + +// queues the same-id market add; bar2 is the collision; bar3 settles. +struct Fixture { + bool is_long; + double limit; + double stop; + bool queue_add; +}; + +class CollisionProbe : public ProbeBase { +public: + CollisionProbe(int pyr, Fixture f) : ProbeBase(pyr), f_(f) {} + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("P", f_.is_long); // base lot1 (market) + } + if (bar_index_ == 1) { + if (f_.queue_add) strategy_entry("P", f_.is_long, kNaN, kNaN, kNaN, "ADD"); + strategy_exit("Px", "P", /*limit=*/f_.limit, /*stop=*/f_.stop); + } + } +private: + Fixture f_; +}; + +static void run4(CollisionProbe& p, const Bar (&bars)[4]) { p.run(bars, 4); } + +// ── LONG + gap-through-STOP → SCRATCH (SCR-OPEN, pnl 0) ────────────────── +static void test_long_gap_stop_scratch() { + std::printf("LONG gap-stop -> SCRATCH\n"); + CollisionProbe p(2, {true, /*limit=*/110.0, /*stop=*/98.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), // bar0: queue base P + mk(100, 100, 100, 100, 1'200'000), // bar1: P fills @100; arm Px + queue add + mk( 97, 97, 96, 96, 1'800'000), // bar2: open 97 <= stop 98 (gap) → collision + mk( 96, 96, 96, 96, 2'400'000), // bar3: settle + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); // pre-fix: +1 (add carried) + CHECK(p.n_trades() == 2); // pre-fix: 1 + CHECK(p.last_is_dur0()); + CHECK(near(p.last_pnl(), 0.0)); // gapped scratch at the open +} + +// ── LONG + gap-through-LIMIT → SCRATCH (SCR-OPEN, pnl 0) ───────────────── +static void test_long_gap_limit_scratch() { + std::printf("LONG gap-limit -> SCRATCH\n"); + CollisionProbe p(2, {true, /*limit=*/103.0, /*stop=*/90.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(104, 105, 104, 104, 1'800'000), // bar2: open 104 >= limit 103 (gap) + mk(104, 104, 104, 104, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); // pre-fix: +1 + CHECK(p.n_trades() == 2); + CHECK(p.last_is_dur0()); + CHECK(near(p.last_pnl(), 0.0)); +} + +// ── SHORT + gap-through-STOP → KEEP (the only keep cell) ───────────────── +// buy-stop exit (prio 1) fills before the sell add (prio 2); the exit is +// sized to the pre-add lot, so the add survives and carries. Unchanged +// pre- and post-fix — this is the cell the two uniform-scratch attempts broke. +static void test_short_gap_stop_keep() { + std::printf("SHORT gap-stop -> KEEP (guard)\n"); + CollisionProbe p(2, {false, /*limit=*/97.0, /*stop=*/102.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(103, 104, 103, 103, 1'800'000), // bar2: open 103 >= stop 102 (gap up) + mk(103, 103, 103, 103, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), -1.0)); // add carried → SHORT 1 (pre + post) + CHECK(p.n_trades() == 1); // only lot1 closed +} + +// ── SHORT + gap-through-LIMIT → SCRATCH (SCR-OPEN, pnl 0) ──────────────── +static void test_short_gap_limit_scratch() { + std::printf("SHORT gap-limit -> SCRATCH\n"); + CollisionProbe p(2, {false, /*limit=*/98.0, /*stop=*/110.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk( 97, 97, 96, 96, 1'800'000), // bar2: open 97 <= limit 98 (gap down) + mk( 96, 96, 96, 96, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); // pre-fix: -1 (add carried) + CHECK(p.n_trades() == 2); + CHECK(p.last_is_dur0()); + CHECK(near(p.last_pnl(), 0.0)); +} + +// ── Intrabar LONG → SCRATCH at bracket level (SCR-INTRA, pnl != 0) ─────── +static void test_intrabar_long_scratch() { + std::printf("intrabar LONG -> SCRATCH (pnl!=0)\n"); + CollisionProbe p(2, {true, /*limit=*/110.0, /*stop=*/98.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(100, 101, 97, 99, 1'800'000), // bar2: open 100 inside; low 97 hits stop 98 + mk( 99, 99, 99, 99, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); // pre-fix: +1 + CHECK(p.n_trades() == 2); + CHECK(p.last_is_dur0()); + CHECK(!near(p.last_pnl(), 0.0)); // add entered @100, exits @98 → pnl != 0 + CHECK(near(p.last_pnl(), -2.0)); // (98 - 100) * 1 +} + +// ── Intrabar SHORT → SCRATCH at bracket level (SCR-INTRA, pnl != 0) ────── +static void test_intrabar_short_scratch() { + std::printf("intrabar SHORT -> SCRATCH (pnl!=0)\n"); + CollisionProbe p(2, {false, /*limit=*/90.0, /*stop=*/102.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk(100, 103, 99, 101, 1'800'000), // bar2: open 100 inside; high 103 hits stop 102 + mk(101, 101, 101, 101, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); // pre-fix: -1 + CHECK(p.n_trades() == 2); + CHECK(p.last_is_dur0()); + CHECK(near(p.last_pnl(), -2.0)); // short add @100 → exit @102 → (100-102)*1 +} + +// ── No-collision control (no add) — unchanged pre/post ────────────────── +static void test_no_collision_control() { + std::printf("no-collision control (guard)\n"); + CollisionProbe p(2, {true, /*limit=*/110.0, /*stop=*/98.0, /*add=*/false}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk( 97, 97, 96, 96, 1'800'000), // bar2: exit fires, lot1 closes + mk( 96, 96, 96, 96, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); + CHECK(p.n_trades() == 1); // only lot1; no add slice +} + +// ── Pyramiding cap: pyr=1 drops the over-cap add (no scratch) ──────────── +// probe65 pin. The add is over cap at fill (count 1 >= pyr 1) → never opens → +// no collision, no scratch. Unchanged pre/post. +static void test_pyr1_add_dropped() { + std::printf("pyr=1 over-cap add dropped (guard)\n"); + CollisionProbe p(1, {true, /*limit=*/110.0, /*stop=*/98.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk( 97, 97, 96, 96, 1'800'000), + mk( 96, 96, 96, 96, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); // add dropped → only lot1 closes → FLAT + CHECK(p.n_trades() == 1); +} + +// ── Pyramiding cap: pyr=2 fills the add → scratch (mirror of long gap-stop) ─ +static void test_pyr2_add_fills_scratch() { + std::printf("pyr=2 add fills → SCRATCH\n"); + CollisionProbe p(2, {true, /*limit=*/110.0, /*stop=*/98.0, /*add=*/true}); + Bar bars[4] = { + mk(100, 100, 100, 100, 600'000), + mk(100, 100, 100, 100, 1'200'000), + mk( 97, 97, 96, 96, 1'800'000), + mk( 96, 96, 96, 96, 2'400'000), + }; + run4(p, bars); + CHECK(near(p.pos_size(), 0.0)); + CHECK(p.n_trades() == 2); // pre-fix: 1 (add carried) + CHECK(p.last_is_dur0()); +} + +int main() { + test_long_gap_stop_scratch(); + test_long_gap_limit_scratch(); + test_short_gap_stop_keep(); + test_short_gap_limit_scratch(); + test_intrabar_long_scratch(); + test_intrabar_short_scratch(); + test_no_collision_control(); + test_pyr1_add_dropped(); + test_pyr2_add_fills_scratch(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}