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
9 changes: 9 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,14 @@ struct PendingOrder {
// Exact clean-room two-call rules must fail closed on this provenance
// rather than mistaking retained priority for current source order.
bool created_by_same_id_replacement = false;
// Exact default MARKET replaced on this source bar. A priced order or
// a prior-bar carry with the same id does not prove this call topology.
uint64_t replaced_default_market_incarnation = 0;
// A filled default-percent short replacement consumes the pending sell
// slot even when its plain transaction leaves an old LONG remainder.
// Mark only the exact later MARKET objects after that fill; a reissue
// creates a fresh object, and cancelled siblings spend no broker event.
bool declined_by_replaced_short_market = false;
// For a strategy.exit replacement, the unique incarnation of the exact
// matching (id, from_entry) EXIT object it replaced. Zero for a fresh
// child. This correlates retained broker priority with a concrete prior
Expand Down Expand Up @@ -3761,6 +3769,7 @@ class BacktestEngine {
// 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.
bool replaced_percent_short_market_is_live(const PendingOrder& order) const;
void apply_market_order_fill(PendingOrder& order, double fill_price,
const Bar& bar,
double& trail_best_path_state,
Expand Down
106 changes: 105 additions & 1 deletion src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4518,7 +4518,8 @@ void BacktestEngine::apply_filled_order_to_state(
// 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) {
if (order.suppress_as_declined_reversal_close
|| order.declined_by_replaced_short_market) {
decline_and_cancel();
return;
}
Expand Down Expand Up @@ -6248,6 +6249,81 @@ static void set_entry_fill_excursion_masks(PyramidEntry& pe, const Bar& bar,
pe.skip_entry_bar_low = (low_pos < fill_pos);
}

// R18 TV replacement pins: with an unchanged LONG lot, calling the same
// default-percent sell MARKET id again replaces its augmented reversal with
// the plain signal-sized transaction. Any later sell MARKET is declined by
// that pending sell slot. 4.54 - 4.53 leaves 0.01 LONG; 3 - 4.53 opens 1.53
// SHORT under the replaced id; equality stays flat. Bracket presence and the
// reissue's position before/after the later sibling do not change the rule.
// The buy-side mirror has a different last-entry outcome. Preserve it and
// the existing priced/FIXED/explicit, fee, FX, risk and scheduler contracts.
bool BacktestEngine::replaced_percent_short_market_is_live(
const PendingOrder& order) const {
if (order.type != OrderType::MARKET || order.is_long
|| !order.created_by_same_id_replacement
|| order.replaced_default_market_incarnation == 0
|| !std::isnan(order.qty) || order.qty_type >= 0
|| order.affordability_close_only || order.sbmt_member
|| order.created_bar != bar_index_ - 1
|| order.created_during_coof_recalc
|| order.created_after_position_close_in_bar
|| order.created_position_side != PositionSide::LONG
|| position_side_ != PositionSide::LONG
|| order.created_position_cycle_seq != position_cycle_seq_
|| order.tv_carry_qty != position_qty_
|| pyramid_entries_.size() != 1
|| pyramid_entries_.front().entry_id == order.id
|| pyramiding_ < 0 || pyramiding_ > 1 || position_entry_count_ != 1
|| default_qty_type_ != QtyType::PERCENT_OF_EQUITY
|| !(default_qty_value_ > 0 && default_qty_value_ < 100)
|| !(qty_step_ > 0)
|| !std::isfinite(order.frozen_default_qty)
|| order.frozen_default_qty <= kQtyEpsilon
|| !order.oca_name.empty() || order.oca_type != 0
|| process_orders_on_close_ || calc_on_order_fills_
|| bar_magnifier_enabled_ || coof_scheduler_active_
|| stream_warmup_mode_ || stream_phase_ != StreamPhase::IDLE
|| slippage_ != 0 || commission_value_ != 0
|| margin_long_ != 100 || margin_short_ != 100
|| syminfo_.pointvalue != 1 || account_currency_fx_ != 1
|| !account_currency_fx_timestamps_.empty()
|| max_intraday_filled_orders_ != 0
|| risk_direction_ != RiskDirection::BOTH
|| risk_max_intraday_loss_ != 0 || risk_max_drawdown_ != 0
|| risk_max_cons_loss_days_ != 0 || risk_max_position_size_ != 0) {
return false;
}
for (const PendingOrder& other : pending_orders_) {
if (&other == &order) continue;
if (other.type == OrderType::EXIT) {
const bool bracket = std::isfinite(other.stop_price)
|| std::isfinite(other.limit_price)
|| std::isfinite(other.profit_ticks)
|| std::isfinite(other.loss_ticks);
if (other.from_entry.empty() || other.requested_partial
|| !std::isnan(other.qty) || other.qty_percent != 100
|| !bracket || other.suppress_as_declined_reversal_close
|| !other.oca_name.empty()
|| !std::isnan(other.trail_points)
|| !std::isnan(other.trail_price)
|| !std::isnan(other.trail_offset)) return false;
continue;
}
// A competing earlier entry, other direction, explicit size, or
// priced/RAW order is outside the covered same-call sell book.
if (other.type != OrderType::MARKET || other.is_long
|| other.created_seq <= order.created_seq
|| other.created_bar != order.created_bar
|| other.created_position_cycle_seq != order.created_position_cycle_seq
|| other.created_after_position_close_in_bar
|| !std::isnan(other.qty) || other.qty_type >= 0
|| other.frozen_default_qty != order.frozen_default_qty
|| other.affordability_close_only || other.sbmt_member
|| !other.oca_name.empty() || other.oca_type != 0) return false;
}
return true;
}

void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_price,
const Bar& bar,
double& trail_best_path_state,
Expand Down Expand Up @@ -6410,6 +6486,31 @@ void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_pr
// bar's close; hand it through as fixed contracts (qty_type < 0) so the
// fill does not re-derive it from the fill price. Explicit-qty and
// FIXED-default orders keep their own (qty, qty_type) pair unchanged.
if (replaced_percent_short_market_is_live(order)) {
// The old from_entry bracket is dormant after a reducing sell and
// reactivates only through its established reissue/margin lifecycle.
// Do not erase pending_orders_ while the fill loop holds references.
mark_position_brackets_dormant_on_declined_reversal(bar);
close_opposite_then_enter(order.id, false, fill_price,
order.frozen_default_qty, -1, /*purge_pending_exits=*/false,
/*explicit_qty_prequantized=*/true, order.incarnation);
for (PendingOrder& sibling : pending_orders_) {
if (sibling.type == OrderType::MARKET
&& sibling.created_seq > order.created_seq
&& sibling.created_bar == order.created_bar && !sibling.is_long) {
sibling.declined_by_replaced_short_market = true;
}
}
if (position_side_ == PositionSide::SHORT && !pyramid_entries_.empty())
pyramid_entries_.back().entry_comment = order.comment;
const double trail_best_after_fill = trail_best_price_;
if (position_side_ == PositionSide::LONG)
trail_best_price_ = std::max(trail_best_price_, bar.high);
else if (position_side_ == PositionSide::SHORT)
trail_best_price_ = std::min(trail_best_price_, bar.low);
trail_best_path_state = trail_best_after_fill;
return;
}
const bool frozen =
!std::isnan(order.frozen_default_qty) || sbmt_flat_frozen_tx;
const bool paired_flat_market =
Expand Down Expand Up @@ -7296,6 +7397,9 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility(
int exit_closed_from_bar, uint64_t exit_closed_from_incarnation,
bool exit_closed_was_long, const Bar& bar) {
using internal::DualEntryStopPathWinner;
if (order.declined_by_replaced_short_market) {
return OrderEligibility::Remove;
}
// 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
Expand Down
8 changes: 8 additions & 0 deletions src/engine_strategy_commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,15 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
}
}
int64_t preserved_seq = 0;
uint64_t replaced_default_market_incarnation = 0;
for (const auto& o : pending_orders_) {
if (o.id == id) {
preserved_seq = o.created_seq;
if (o.type == OrderType::MARKET && o.created_bar == bar_index_
&& o.is_long == is_long && std::isnan(o.qty) && o.qty_type < 0
&& o.created_position_cycle_seq == position_cycle_seq_) {
replaced_default_market_incarnation = o.incarnation;
}
break;
}
}
Expand Down Expand Up @@ -559,6 +565,8 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
order.created_seq = preserved_seq > 0 ? preserved_seq : next_order_seq_++;
order.incarnation = next_order_incarnation_++;
order.created_by_same_id_replacement = preserved_seq > 0;
order.replaced_default_market_incarnation =
replaced_default_market_incarnation;
if (preserved_seq == 0) {
order.recreated_after_named_cancelled_entry_incarnation =
named_cancel_context.entry_incarnation;
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ set(TEST_SOURCES
test_strategy_commands_extra
test_multi_tier_exit_precedence
test_same_tick_multi_entry_race
test_replaced_percent_short_market
test_full_close_while_pyramiding
test_integer_lot_percent_exit_min_step
test_deferred_flip_carry_close_only
Expand Down
204 changes: 204 additions & 0 deletions tests/test_replaced_percent_short_market.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// R18 covered TV controls (OANDA:XAUUSD, 2025-04-01..2026-05-01):
// same-id default-percent short replacement is a plain sell transaction.
// Seed 4.54, First 4.53 => 0.01 LONG; seed 10 => 5.47 LONG;
// seed 3 => 1.53 SHORT under First; equal quantities => FLAT. A later
// same-direction MARKET does not fill. The old long bracket stays dormant
// until reissued. The buy-side mirror is deliberately outside this fix.
// Small synthetic unit bars below scale that arithmetic to 3 - 2 = 1.
#include <cmath>
#include <cstdio>
#include <limits>
#include <string>
#include <vector>
#include <pineforge/engine.hpp>

using namespace pineforge;
namespace {
constexpr double nan = std::numeric_limits<double>::quiet_NaN();
int failed = 0;
int passed = 0;
#define CHECK(x) do { if (x) ++passed; else { ++failed; \
std::printf("FAIL %d: %s\n", __LINE__, #x); } } while (0)
bool near(double a, double b) { return std::abs(a-b) < 1e-9; }

class Probe : public BacktestEngine {
public:
double seed_qty = 3;
int calls = 2;
bool sibling = true, child = true, last_child = true;
bool mirror = false, revive = false, default_seed = false;
bool long_only_at_race = false;
bool replace_after_sibling = false, explicit_qty = false;
bool priced_first = false, cancel_first = false, reenter = false;
int issued_calls = 0;
struct State { PositionSide side; double qty; std::string id; size_t closed; };
std::vector<State> seen;
std::vector<Trade> closed;
Probe() {
initial_capital_ = 10000;
default_qty_type_ = QtyType::PERCENT_OF_EQUITY;
default_qty_value_ = 2;
pyramiding_ = 1;
commission_value_ = 0;
slippage_ = 0;
qty_step_ = 0.01;
}
void percent(double value) { default_qty_value_ = value; }
void first() {
if (cancel_first && issued_calls == 1) strategy_cancel("First");
strategy_entry("First", mirror, nan,
priced_first && issued_calls == 0 ? 90 : nan,
explicit_qty ? 2 : nan, "");
++issued_calls;
if (child) strategy_exit("First exit", "First", mirror ? 120 : 80,
mirror ? 80 : 120, nan, nan, nan, 100, "", nan, "");
}
void on_bar(const Bar&) override {
if (bar_index_ == 0) {
issued_calls = 0;
strategy_entry("Seed", !mirror, nan, nan,
default_seed ? nan : seed_qty, "");
strategy_exit("Seed exit", "Seed", mirror ? 80 : 110,
mirror ? 120 : 80, nan, nan, nan, 100, "", nan, "");
}
if (bar_index_ == 2) {
if (long_only_at_race) risk_direction_ = RiskDirection::LONG_ONLY;
for (int i=0; i<(replace_after_sibling ? 1 : calls); ++i) first();
if (sibling) {
strategy_entry("Last", mirror, nan, nan, nan, "");
if (last_child) strategy_exit("Last exit", "Last", mirror ? 125 : 75,
mirror ? 75 : 125, nan, nan, nan, 100, "", nan, "");
}
if (replace_after_sibling) for (int i=1;i<calls;++i) first();
}
if (bar_index_ == 4 && reenter)
strategy_entry("Last", false, nan, nan, nan, "");
if (bar_index_ == 5 && revive) {
strategy_exit("Seed exit", "Seed", 110, 80,
nan, nan, nan, 100, "", nan, "");
}
seen.push_back({position_side_,position_qty_,
pyramid_entries_.empty() ? "" : pyramid_entries_.front().entry_id,
trades_.size()});
closed = trades_;
}
};
std::vector<Bar> feed(bool touch = false) {
std::vector<Bar> bars(8);
for (int i=0;i<8;++i) bars[i] = {
100,100.5,99.5,100,1000,(i+1)*900000LL};
if (touch) { bars[4].high=112; bars[6].high=112; }
return bars;
}
void run(Probe& p,bool touch=false) {
p.seen.clear(); p.closed.clear();
const auto bars=feed(touch); p.run(bars.data(),static_cast<int>(bars.size()));
}
void test_partial_and_topology() {
for (int variant=0;variant<6;++variant) {
Probe p;
p.sibling=variant!=1; p.child=variant!=2;
p.calls=variant==3 ? 3 : 2;
p.replace_after_sibling=variant==4;
p.last_child=variant!=5;
run(p); run(p); // reuse must not carry a cancelled sibling marker
CHECK(p.seen[3].side==PositionSide::LONG);
CHECK(near(p.seen[3].qty,1));
CHECK(p.seen[3].id=="Seed");
CHECK(p.closed.size()==1);
if (p.closed.size()==1) {
CHECK(near(p.closed[0].qty,2));
CHECK(p.closed[0].entry_id=="Seed");
CHECK(p.closed[0].exit_id=="First");
}
}
}
void test_equal_and_crossing() {
Probe equal; equal.seed_qty=2; run(equal);
CHECK(equal.seen[3].side==PositionSide::FLAT);
CHECK(near(equal.seen[3].qty,0));
CHECK(equal.closed.size()==1);
Probe cross; cross.seed_qty=1; run(cross);
CHECK(cross.seen[3].side==PositionSide::SHORT);
CHECK(near(cross.seen[3].qty,1));
CHECK(cross.seen[3].id=="First");
CHECK(cross.closed.size()==1);
Probe tiny; tiny.seed_qty=2.01; run(tiny);
CHECK(tiny.seen[3].side==PositionSide::LONG);
CHECK(near(tiny.seen[3].qty,0.01));
}
void test_old_bracket_lifetime() {
Probe p; p.revive=true; run(p,true);
CHECK(p.seen[4].side==PositionSide::LONG);
CHECK(near(p.seen[4].qty,1));
CHECK(p.seen[6].side==PositionSide::FLAT);
CHECK(p.closed.size()==2);
if (p.closed.size()==2) {
CHECK(p.closed[1].entry_id=="Seed");
CHECK(p.closed[1].exit_id=="Seed exit");
CHECK(near(p.closed[1].qty,1));
CHECK(near(p.closed[1].exit_price,110));
}
}
void test_default_seed_and_high_percent() {
Probe p; p.default_seed=true;
auto bars=feed();
for (int i=0;i<2;++i) bars[i]={99,99.5,98.5,99,1000,(i+1)*900000LL};
p.run(bars.data(),static_cast<int>(bars.size()));
CHECK(near(p.seen[1].qty,2.02));
CHECK(p.seen[3].side==PositionSide::LONG);
CHECK(near(p.seen[3].qty,0.02));
CHECK(p.closed.size()==1);
if (p.closed.size()==1) CHECK(near(p.closed[0].qty,2));
// Reversal admission has held=0 (only SAME-direction adds reserve the
// held margin), so a funded 75/99-percent sell is not declined at 50%.
for (double pct : {51.0,75.0,99.0}) {
Probe high; high.percent(pct); run(high);
CHECK(high.seen[3].side==PositionSide::SHORT);
CHECK(near(high.seen[3].qty,pct-3));
CHECK(high.seen[3].id=="First");
CHECK(high.closed.size()==1);
}
}
void test_direction_risk_exclusion() {
Probe p; p.seed_qty=1; p.long_only_at_race=true; run(p);
CHECK(p.seen[3].side==PositionSide::FLAT);
CHECK(near(p.seen[3].qty,0));
CHECK(p.closed.size()==1);
if (p.closed.size()==1) CHECK(near(p.closed[0].qty,1));
}
// Preserve the existing engine lanes that this narrow sell-side repair
// does not claim to redefine. The old same-tick suite pins their details.
void test_excluded_lanes() {
Probe single; single.calls=1; single.sibling=false; run(single);
CHECK(single.seen[3].side==PositionSide::SHORT);
CHECK(near(single.seen[3].qty,2));
CHECK(single.seen[3].id=="First");
Probe mirror; mirror.mirror=true; run(mirror);
CHECK(mirror.seen[3].side==PositionSide::LONG);
CHECK(near(mirror.seen[3].qty,2));
CHECK(mirror.seen[3].id=="Last");
Probe explicit_order; explicit_order.explicit_qty=true;
explicit_order.sibling=false; run(explicit_order);
CHECK(explicit_order.seen[3].side==PositionSide::SHORT);
CHECK(near(explicit_order.seen[3].qty,2));
for (bool priced : {false,true}) {
Probe replaced; replaced.sibling=false;
replaced.priced_first=priced; replaced.cancel_first=!priced;
run(replaced);
CHECK(replaced.seen[3].side==PositionSide::SHORT);
CHECK(near(replaced.seen[3].qty,2));
}
Probe fresh; fresh.reenter=true; run(fresh);
CHECK(fresh.seen[3].side==PositionSide::LONG);
CHECK(fresh.seen[5].side==PositionSide::SHORT);
CHECK(near(fresh.seen[5].qty,2));
}
}
int main() {
test_partial_and_topology(); test_equal_and_crossing();
test_old_bracket_lifetime(); test_excluded_lanes();
test_default_seed_and_high_percent(); test_direction_risk_exclusion();
std::printf("%d passed, %d failed\n",passed,failed);
return failed ? 1 : 0;
}
Loading