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
33 changes: 32 additions & 1 deletion include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,29 @@ struct PendingOrder {
std::string comment; // order comment for trade reporting
bool requested_partial = false; // true iff caller passed qty_percent < 100
bool created_while_in_position = false; // true if position was open when order was placed
// design-declined-reversal-close-leg: set at the KI-54 percent-of-equity
// reversal-decline site when this pending FULL close was co-queued AFTER,
// and on the same bar as, the declined MARKET reversal entry targeting the
// position that reversal would have flipped. TradingView refuses the whole
// reversal atomically and HOLDS the position, so the co-queued close must
// not fire either. classify_order_eligibility Removes a flagged order from
// BOTH fill kernels; apply_filled_order_to_state additionally no-ops it at
// apply time (the KI-60 COOF kernel pre-classifies its candidates, so a flag
// set mid-segment by an earlier candidate's decline is only seen there). On
// an ADMITTED reversal the entry fills and the close is a plain no-op — the
// flag is never set, so the fix is inert. See suppress_declined_reversal_
// close_legs (engine_fills.cpp).
bool suppress_as_declined_reversal_close = false;
// Qty this deferred close debited from id_unclosed_qty_[<bare id>] in
// compute_close_target_qty's default-FIFO branch at strategy.close CALL
// time. On the false->true suppression transition it is re-credited to that
// ledger EXACTLY ONCE, so a later strategy.close(id) on the still-held
// position resolves a nonzero target and fires (precedent: the COOF reissue
// re-credit, engine_strategy_commands.cpp). NaN = nothing to re-credit (the
// ANY close-entries rule, an explicit qty/qty_percent, and close_all do not
// debit the id ledger).
double suppressed_close_consumed_ledger_qty =
std::numeric_limits<double>::quiet_NaN();
};

// default_qty_type constants (matches TradingView)
Expand Down Expand Up @@ -1777,6 +1800,12 @@ class BacktestEngine {
int& exit_closed_from_bar,
bool& exit_closed_was_long,
std::vector<size_t>& filled_indices);
// design-declined-reversal-close-leg: called at the KI-54 reversal-decline
// site with the just-declined MARKET reversal entry. Flags every pending
// FULL close that was co-queued after it on the same bar against the held
// side (see PendingOrder::suppress_as_declined_reversal_close), re-crediting
// each flagged close's consumed id-ledger exactly once.
void suppress_declined_reversal_close_legs(const PendingOrder& declined_entry);
// Per-OrderType fill kernels. Called only after risk + intraday
// gates pass; each updates the engine's position/trade state and
// any per-type out-parameters the post-fill bookkeeping needs.
Expand Down Expand Up @@ -1861,7 +1890,9 @@ class BacktestEngine {
double qty_to_close,
double matching_qty,
bool closes_full_position,
bool closes_any_qty);
bool closes_any_qty,
double consumed_ledger_qty =
std::numeric_limits<double>::quiet_NaN());
void clear_existing_exit_order(const std::string& id,
const std::string& from_entry,
bool has_trail_request,
Expand Down
80 changes: 80 additions & 0 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,17 @@ void BacktestEngine::apply_filled_order_to_state(
int& exit_closed_from_bar,
bool& exit_closed_was_long,
std::vector<size_t>& filled_indices) {
// design-declined-reversal-close-leg: a close flagged by the reversal
// decline is Removed by classify_order_eligibility in the ordinary kernel,
// so this order never reaches apply there. The KI-60 COOF kernel, however,
// pre-classifies its whole candidate set BEFORE any candidate is applied,
// so a flag set mid-segment by an earlier candidate's decline is not seen
// by classify — catch it here (no-op the fill, mark for compaction). Shared
// by both kernels; must precede every state mutation below.
if (order.suppress_as_declined_reversal_close) {
filled_indices.push_back(order_index);
return;
}
// Fill-local proof that KI-54 admitted this order as a flat open on its
// frozen sizing price. Merely carrying a snapshot is insufficient: true
// reversals are admitted on their actual fill, and paired reentries may
Expand Down Expand Up @@ -1040,6 +1051,14 @@ void BacktestEngine::apply_filled_order_to_state(
* account_currency_fx_
* (margin_pct / 100.0));
if (required_margin > free_funds + epsilon) {
// design-declined-reversal-close-leg: ONLY the reversal decline
// triggers close-leg suppression (admit_price == slipped fill,
// MARKET). The same_dir add decline (probe65 shape) and the
// disjoint gap-reject/GB2 declines above are intentionally
// excluded — see suppress_declined_reversal_close_legs.
if (reversal && order.type == OrderType::MARKET) {
suppress_declined_reversal_close_legs(order);
}
filled_indices.push_back(order_index);
return;
}
Expand Down Expand Up @@ -1751,6 +1770,60 @@ void BacktestEngine::materialize_relative_exit_prices_for_live_position() {
}


// design-declined-reversal-close-leg. When the KI-54 percent-of-equity gate
// declines a MARKET reversal entry at fill, TradingView refuses the whole
// reversal ATOMICALLY and HOLDS the position — so a strategy.close leg
// co-queued AFTER that reversal on the SAME bar, targeting the very position
// the reversal would have flipped, must not fire either (the pre-fix engine let
// it fill and went flat, then re-entered on a later mid-span signal TV no-ops).
// Flag every matching pending close; classify_order_eligibility and the
// apply-time guard then Remove it from both fill kernels. NEVER erase
// pending_orders_ in place and NEVER push later indices into filled_indices
// here — that would corrupt the fill loop / compaction binary-search invariant.
//
// Binding (design doc item 3, verified against the actual queue_deferred_close_
// order / strategy.close conventions):
// - EXIT order whose id has the "__close__" prefix WITH a nonempty target
// (bare "__close__" close_all is out of scope — R5 characterization freeze);
// - created on the SAME bar (created_bar) as the declined entry — its signal
// bar, not bar_index_;
// - created AFTER the declined entry (created_seq): a close created FIRST
// fires (chawarat's sell leg, R7 — and the sort processes it before the
// entry anyway);
// - against the HELD side (created_position_side == position_side_, still the
// held side at decline time, the reversal not yet applied);
// - FULL close only (qty_percent >= 100-eps && isnan(qty)); partial closes
// are excluded (no exemplar — R7/partial-close row) and documented.
//
// Ledger re-credit (design doc item 4): the deferred close debited
// id_unclosed_qty_[<bare id>] at strategy.close CALL time. Re-credit it EXACTLY
// ONCE, on the false->true flag transition, so a later close(id) on the still-
// held position resolves a nonzero target and fires. The `continue` on an
// already-flagged order makes a second same-bar decline idempotent (single
// re-credit).
void BacktestEngine::suppress_declined_reversal_close_legs(
const PendingOrder& declined_entry) {
static const std::string kClosePrefix = "__close__";
for (PendingOrder& co : pending_orders_) {
if (co.suppress_as_declined_reversal_close) continue; // idempotent
if (co.type != OrderType::EXIT) continue;
if (co.id.size() <= kClosePrefix.size()) continue; // bare close_all excluded
if (co.id.compare(0, kClosePrefix.size(), kClosePrefix) != 0) continue;
if (co.created_bar != declined_entry.created_bar) continue;
if (co.created_seq <= declined_entry.created_seq) continue; // created-after only
if (co.created_position_side != position_side_) continue; // held side
const bool full_close =
std::isnan(co.qty) && co.qty_percent >= 100.0 - kFullPercentEps;
if (!full_close) continue;
co.suppress_as_declined_reversal_close = true; // false->true transition
if (!std::isnan(co.suppressed_close_consumed_ledger_qty)
&& co.suppressed_close_consumed_ledger_qty > 0.0) {
id_unclosed_qty_[co.id.substr(kClosePrefix.size())]
+= co.suppressed_close_consumed_ledger_qty;
}
}
}

// ── Inner-loop phase 1: order eligibility ─────────────────────────────
// Returns whether the given pending order should be processed this
// iteration. Walks the chain of TV-empirical "skip" / "cancel" rules
Expand All @@ -1761,6 +1834,13 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility(
const std::unordered_set<std::string>& pass0_opposing_skip_ids,
int exit_closed_from_bar, bool exit_closed_was_long, const Bar& bar) {
using internal::DualEntryStopPathWinner;
// design-declined-reversal-close-leg: a close flagged at the KI-54 reversal
// decline is held atomically with the refused reversal — Remove it from both
// fill kernels before any other classification runs. Unconditional (across
// both opposing passes): a flagged order is never eligible.
if (order.suppress_as_declined_reversal_close) {
return OrderEligibility::Remove;
}
if (opposing_pass == 1) {
if (!pass0_opposing_skip_ids.count(order.id)) {
return OrderEligibility::Skip;
Expand Down
29 changes: 23 additions & 6 deletions src/engine_strategy_commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -370,17 +370,28 @@ void BacktestEngine::strategy_close(const std::string& id, const std::string& co
return;
}

// design-declined-reversal-close-leg: compute_close_target_qty's default-
// FIFO branch (below condition) debited id_unclosed_qty_[id] by
// qty_to_close. Record it on the deferred close so a later reversal-decline
// suppression can re-credit exactly that amount — UNLESS the POOC recalc
// block below re-credits it immediately (guard against a double-credit).
const bool default_fifo_close = !close_entries_rule_any_ && !id.empty()
&& std::isnan(qty) && std::isnan(qty_percent);
const bool immediate_ledger_recredit = coof_scheduler_active_
&& coof_fill_recalc_active_ && coof_cursor_is_bar_close_
&& process_orders_on_close_ && default_fifo_close;
const double consumed_ledger_qty =
(default_fifo_close && !immediate_ledger_recredit)
? qty_to_close : std::numeric_limits<double>::quiet_NaN();
queue_deferred_close_order(id, comment, qty_to_close, matching_qty,
closes_full_position, closes_any_qty);
closes_full_position, closes_any_qty,
consumed_ledger_qty);
// A default-FIFO close consumes id_unclosed_qty_ while resolving its
// target above. When the command was born after an already-consumed POOC
// close, its market order expires without a broker tick; keep the logical
// entry ledger available so a later ordinary-close execution can reissue
// and actually fill the close (Delta's next-bar lifecycle).
if (coof_scheduler_active_ && coof_fill_recalc_active_
&& coof_cursor_is_bar_close_ && process_orders_on_close_
&& !close_entries_rule_any_ && !id.empty()
&& std::isnan(qty) && std::isnan(qty_percent)) {
if (immediate_ledger_recredit) {
id_unclosed_qty_[id] += qty_to_close;
}
}
Expand Down Expand Up @@ -1067,7 +1078,8 @@ void BacktestEngine::queue_deferred_close_order(const std::string& id,
double qty_to_close,
double matching_qty,
bool closes_full_position,
bool closes_any_qty) {
bool closes_any_qty,
double consumed_ledger_qty) {
const double eps = kQtyEpsilon;
PendingOrder order;
order.id = "__close__" + id;
Expand Down Expand Up @@ -1100,6 +1112,11 @@ void BacktestEngine::queue_deferred_close_order(const std::string& id,
order.tv_carry_qty = position_qty_;
order.comment = comment;
order.created_while_in_position = true;
// design-declined-reversal-close-leg: the qty this close debited from
// id_unclosed_qty_ at CALL time (default-FIFO branch), so a later
// suppression can re-credit exactly that amount. NaN when nothing was
// debited (ANY rule / explicit qty / close_all).
order.suppressed_close_consumed_ledger_qty = consumed_ledger_qty;

pending_orders_.push_back(std::move(order));
}
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ set(TEST_SOURCES
test_full_close_while_pyramiding
test_deferred_flip_carry_close_only
test_close_all_coqueued_entry
test_declined_reversal_close_leg
test_lower_tf_parse_extra
test_ta_ma_warmup_extra
test_ta_osc_edge
Expand Down
Loading
Loading