diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index ea5451b..1949f96 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -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 @@ -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, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 0d3740e..9931a7b 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -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; } @@ -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, @@ -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 = @@ -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 diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 16d7020..ea467b8 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -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; } } @@ -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; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0c07776..9865ff8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/test_replaced_percent_short_market.cpp b/tests/test_replaced_percent_short_market.cpp new file mode 100644 index 0000000..3021c98 --- /dev/null +++ b/tests/test_replaced_percent_short_market.cpp @@ -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 +#include +#include +#include +#include +#include + +using namespace pineforge; +namespace { +constexpr double nan = std::numeric_limits::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 seen; + std::vector 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 feed(bool touch = false) { + std::vector 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(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(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; +}