Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
73 changes: 69 additions & 4 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 53 additions & 1 deletion src/engine_orders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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});
}
}

Expand Down Expand Up @@ -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<PyramidEntry> 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;
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading