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
10 changes: 10 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,16 @@ class BacktestEngine {
void apply_exit_order_fill(PendingOrder& order, double fill_price,
int& exit_closed_from_bar,
bool& exit_closed_was_long);
// Freeze the reserved qty of LAYERED strategy.exit legs (a qty_percent<100
// partial + a sibling default/100% leg) that were armed while the position
// was FLAT (their entry still pending) and therefore stored qty=NaN. Called
// when such an entry first opens a position: each leg is bound to a fixed
// share of the just-opened lot so it no longer over-closes depending on
// sibling fill order. Mirrors TV binding each bracket leg to a fixed slice
// of the entry it attaches to. Only acts on multi-leg from_entry groups that
// contain at least one partial leg; single brackets and pure 100% OCA pairs
// are left untouched (qty=NaN → full remaining close, as before).
void reconcile_deferred_layered_exits(const std::string& entry_id);
void apply_raw_order_fill(PendingOrder& order, double fill_price,
double& trail_best_path_state,
int& exit_closed_from_bar,
Expand Down
62 changes: 62 additions & 0 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,19 @@ void BacktestEngine::apply_filled_order_to_state(
double signed_pos_after = signed_pos();
double filled_qty = std::abs(signed_pos_after - signed_pos_before);

// This fill just opened a position from FLAT via an entry order. Freeze
// any LAYERED strategy.exit legs bound to that entry that were armed while
// flat (qty=NaN, reservation deferred): bind each to a fixed slice of the
// opened lot so a percent partial + its sibling 100% leg no longer
// over-close the whole position depending on which leg fills first.
if (std::abs(signed_pos_before) < kQtyEpsilon
&& position_side_ != PositionSide::FLAT
&& (order.type == OrderType::MARKET
|| order.type == OrderType::ENTRY
|| order.type == OrderType::RAW_ORDER)) {
reconcile_deferred_layered_exits(order.id);
}

if (position_side_ == PositionSide::FLAT) {
trail_best_path_state = trail_best_price_;
}
Expand Down Expand Up @@ -863,6 +876,55 @@ void BacktestEngine::apply_exit_order_fill(PendingOrder& order, double fill_pric
}
}

void BacktestEngine::reconcile_deferred_layered_exits(const std::string& entry_id) {
if (entry_id.empty()) return;
const double live_pos = position_qty_;
if (live_pos <= kQtyEpsilon) return;

// Only act on a LAYERED construct: a from_entry group with >=2 pending
// exit legs where at least one is a partial (qty_percent < 100). A lone
// bracket or a pure 100% OCA TP/SL pair carries no partial-vs-100% fill-
// order ambiguity and is left deferred (qty=NaN → full remaining close).
int leg_count = 0;
bool has_partial = false;
for (const auto& o : pending_orders_) {
if (o.type != OrderType::EXIT) continue;
if (o.from_entry != entry_id) continue;
++leg_count;
double oqp = std::isnan(o.qty_percent)
? 100.0 : std::clamp(o.qty_percent, 0.0, 100.0);
if (oqp < 100.0 - kFullPercentEps) has_partial = true;
}
if (leg_count < 2 || !has_partial) return;

// Walk the group in arm (pending) order, reserving each leg's share of the
// opened lot exactly like compute_exit_reserved_qty would have if the
// position had been live at arm time: a partial reserves its floored
// percent slice; the 100% sibling reserves whatever remains. Freezing an
// explicit qty makes each leg close a fixed amount regardless of which
// fires first. Legs that already carry an explicit qty (reconciled at arm
// time) are left as-is but still consume reservation capacity.
double reserved = 0.0;
for (auto& o : pending_orders_) {
if (o.type != OrderType::EXIT) continue;
if (o.from_entry != entry_id) continue;
double oqp = std::isnan(o.qty_percent)
? 100.0 : std::clamp(o.qty_percent, 0.0, 100.0);
if (!std::isnan(o.qty)) { // already reconciled at arm time
reserved += o.qty;
continue;
}
double avail = std::max(0.0, live_pos - reserved);
double requested = live_pos * (oqp / 100.0);
if (oqp < 100.0 - kFullPercentEps) requested = apply_exit_qty_step(requested);
double res = std::min(requested, avail);
if (res <= kQtyEpsilon) continue; // nothing left to reserve; leave deferred
o.qty = res;
o.requested_partial = res < live_pos - kFullQtyEps;
reserved += res;
}
}

void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price,
double& trail_best_path_state,
int& exit_closed_from_bar,
Expand Down
Loading