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
14 changes: 9 additions & 5 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1338,7 +1338,8 @@ class BacktestEngine {
// Returns true when a "Margin call" row was booked; the triggering exit
// then fills the reduced remainder.
bool margin_call_slice_before_priced_exit(const Bar& bar,
double exit_fill_price);
double exit_fill_price,
double exit_path_position);
// finding-325 (1x-long entry-fill affordability chronology): TV runs the
// 1x-long (margin_long=100) opening-affordability check AT THE ENTRY
// FILL, chronologically before the same bar's intrabar exits. When a
Expand Down Expand Up @@ -2535,12 +2536,15 @@ class BacktestEngine {
// limit, or the limit leg of an entry stop-limit) — routes the
// fill onto the unslipped limit-or-better price path.
bool is_limit_fill = false;
// True when the fill came from resolve_exit_path_fill's stop/limit
// walk of the intrabar path for an exit-style order (not a TRAIL
// fill, and not a market / same-bar-close-priced exit). Only such
// fills carry a meaningful chronological path position, which the
// True when the fill came from resolve_exit_path_fill's walk of the
// intrabar path for an exit-style order (stop, limit, gap-open or
// TRAIL leg — but not a market / same-bar-close-priced exit). Only
// such fills carry a chronological path position, which the
// finding-308 pre-exit margin-call slice requires.
bool exit_path_fill = false;
// The fill's position on the bar's 4-waypoint path, in
// first_touch_position units. Set whenever exit_path_fill is true.
double exit_path_position = std::numeric_limits<double>::quiet_NaN();
};
FillEvaluation evaluate_fill_price(
PendingOrder& order, size_t order_index, const Bar& bar,
Expand Down
71 changes: 54 additions & 17 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ void BacktestEngine::process_pending_orders(const Bar& bar) {
// pre-on_bar equity, so re-freeze default-sized market orders
// exactly like the end-of-bar call sites do.
if (fill.exit_path_fill
&& margin_call_slice_before_priced_exit(bar, fill.fill_price)) {
&& margin_call_slice_before_priced_exit(
bar, fill.fill_price, fill.exit_path_position)) {
refresh_frozen_default_sizing_after_margin_call();
}

Expand Down Expand Up @@ -1000,7 +1001,6 @@ void BacktestEngine::process_margin_call(const Bar& bar) {
// commission-free. Competing fallbacks scored 0/974 each: one
// qty_step, 4 qty_step, the whole residual, 1% of the position.
//
// Only the opt-in whole-residual interpretation keeps precedence.
// The structural guards are the same ones the converted-currency
// carried-rollover helper above uses: the restore quantity must be
// real and sub-contract, and the instrument's lot grid must be able
Expand All @@ -1023,10 +1023,26 @@ void BacktestEngine::process_margin_call(const Bar& bar) {
one_contract_fallback = candidate;
}
}
if (margin_zero_cover_full_liquidation_) {
floored = qty;
} else if (std::isfinite(one_contract_fallback)) {
// The settled slice rule stays authoritative wherever it can
// express a fill, INCLUDING under the opt-in whole-residual
// interpretation. At eps-scale free-margin deficits (~0.05-0.5
// USD on the ETH tapes) a multi-contract position's restore
// quantity floors to zero and TV closes exactly ONE contract —
// or, one lot richer, tiny 4x nibbles — and HOLDS the remainder
// (boztilkiserhan-serhan-1 ADX 2025-06-08 / 2026-01-17 six
// partials 0.0004-0.0804 / 2026-01-26; finding 279). Letting the
// full-residual opt-in take precedence here liquidated the
// ENTIRE position at the adverse extreme, an exit TV never
// prints. The opt-in now covers the whole residual only when the
// one-contract fallback cannot express a fill at all (raw
// restore not real/sub-contract, lot grid unable to carry one
// contract); for a sub-one-contract position both readings
// coincide (min(1.0, qty) == qty), so the opt-in's original
// oracle (sub-lot $100-scale shorts) is untouched.
if (std::isfinite(one_contract_fallback)) {
floored = one_contract_fallback;
} else if (margin_zero_cover_full_liquidation_) {
floored = qty;
} else {
return;
}
Expand Down Expand Up @@ -1156,7 +1172,7 @@ void BacktestEngine::revive_position_brackets_after_margin_call_partial(
// and the COOF scheduler own finer-grained tick/recalc chronology models and
// keep the established once-per-script-bar placement (no exemplar there).
bool BacktestEngine::margin_call_slice_before_priced_exit(
const Bar& bar, double exit_fill_price) {
const Bar& bar, double exit_fill_price, double exit_path_position) {
if (!margin_call_enabled_) return false;
if (position_side_ == PositionSide::FLAT) return false;
if (last_margin_call_event_bar_ == bar_index_) return false;
Expand Down Expand Up @@ -1198,6 +1214,13 @@ bool BacktestEngine::margin_call_slice_before_priced_exit(
// rule) than the exit's fill. An off-path level fails closed. A tie —
// the exit filling exactly at the extreme, e.g. a stop-loss riding the
// adverse leg — keeps the exit first.
//
// exit_path_position is the walk's OWN answer for where the exit filled,
// in the same units first_touch_position produces. Prefer it: it is the
// only correct reading for a TRAIL leg, whose level is not a resting one
// (the trail must arm before it fires, so the fill price's first path
// touch can precede the fill). A caller with no resolved position falls
// back to the price's first touch.
const double adverse =
(position_side_ == PositionSide::LONG) ? bar.low : bar.high;
if (!std::isfinite(adverse) || !(adverse > 0.0)) return false;
Expand All @@ -1206,7 +1229,10 @@ bool BacktestEngine::margin_call_slice_before_priced_exit(
if (!internal::first_touch_position(bar, adverse, &adverse_pos)) {
return false;
}
if (!internal::first_touch_position(bar, exit_fill_price, &exit_pos)) {
if (std::isfinite(exit_path_position)) {
exit_pos = exit_path_position;
} else if (!internal::first_touch_position(bar, exit_fill_price,
&exit_pos)) {
return false;
}
if (!(adverse_pos < exit_pos - kPathPosEps)) return false;
Expand Down Expand Up @@ -1258,10 +1284,16 @@ bool BacktestEngine::margin_call_slice_before_priced_exit(
one_contract_fallback = candidate;
}
}
if (margin_zero_cover_full_liquidation_) {
floored = qty;
} else if (std::isfinite(one_contract_fallback)) {
// Same precedence as the cascade above: the settled slice rule
// stays authoritative wherever it can express a fill, including
// under the full-residual opt-in. This copy of the arithmetic is
// reached when the deficit is discovered chronologically, before
// a same-bar priced exit — the eps-deficit shape does not stop
// being an eps-deficit because it was found there.
if (std::isfinite(one_contract_fallback)) {
floored = one_contract_fallback;
} else if (margin_zero_cover_full_liquidation_) {
floored = qty;
} else {
return false;
}
Expand Down Expand Up @@ -5460,6 +5492,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price(
bool should_fill = false;
bool is_limit_fill = false;
bool exit_path_fill = false;
double exit_path_position = std::numeric_limits<double>::quiet_NaN();

// A valid child that was armed with its pending MARKET parent and whose
// stop is already breached — or whose limit is already marketable — at
Expand Down Expand Up @@ -5584,12 +5617,16 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price(
should_fill = true;
last_exit_fill_was_trail_ = exit_fill.is_trail;
is_limit_fill = exit_fill.is_limit;
// finding-308: only a stop/limit fill resolved on the intrabar
// path carries a chronological position the pre-exit margin-call
// slice can compare against the adverse extreme. A TRAIL fill
// price is not a resting level (its first path touch is not its
// fill moment), so it stays outside the hook — fail closed.
exit_path_fill = !exit_fill.is_trail;
// finding-308: a fill resolved on the intrabar path carries the
// chronological position the pre-exit margin-call slice compares
// against the adverse extreme. resolve_exit_path_fill reports it
// directly, so the TRAIL leg participates too — its fill price
// is not a resting level (its first path touch is not its fill
// moment), which is exactly why the position must come from the
// walk rather than from first_touch_position(fill price). A
// fill without a resolved position still fails closed.
exit_path_position = exit_fill.path_position;
exit_path_fill = std::isfinite(exit_path_position);
}
} else if (!should_fill && (order.type == OrderType::MARKET ||
(!has_stop && !has_limit && !has_trail))) {
Expand Down Expand Up @@ -5676,7 +5713,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price(
}

return {should_fill ? FillEvaluation::Kind::Fill : FillEvaluation::Kind::NoFill,
fill_price, is_limit_fill, exit_path_fill};
fill_price, is_limit_fill, exit_path_fill, exit_path_position};
}

} // namespace pineforge
8 changes: 8 additions & 0 deletions src/engine_internal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ struct ExitPathFill {
// engine.hpp); the fill-application code needs to know which leg fired
// because price equality cannot distinguish a gap fill at the open.
bool is_limit = false;
// Where the fill happened on the bar's 4-waypoint synthesized path, in
// first_touch_position units (0 = open, 1/2 = the extremes, 3 = close;
// fractional inside a segment). This is the fill's ACTUAL chronology,
// which for a TRAIL leg is not recoverable from the fill price alone —
// a trail's level is not a resting one, so its first path touch can
// precede the moment it arms and fires. finding-308's pre-exit
// margin-call slice compares this against the adverse extreme.
double path_position = std::numeric_limits<double>::quiet_NaN();
};


Expand Down
13 changes: 13 additions & 0 deletions src/engine_path_resolve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,8 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar,
&& (!is_entry_bar || magnifier_active || cascade_wp_gap)) {
if (try_exit_open_gap_fill(bar, is_long, has_stop, stop_price,
has_limit, limit_price, trail, &fill)) {
// A gap fill happens at the bar's open — path position 0.
fill.path_position = 0.0;
return fill;
}
}
Expand Down Expand Up @@ -904,6 +906,17 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar,
fill.fill_price = events.ev[0].price;
fill.is_trail = (events.ev[0].kind == PathCrossKind::TRAIL);
fill.is_limit = (events.ev[0].kind == PathCrossKind::LIMIT);
// Chronology of the fill itself, in first_touch_position units.
// Interpolate against the FULL segment (path[seg_idx-1] ->
// path[seg_idx]) even when a mid-path cursor truncated it, so
// the scale matches first_touch_position's exactly.
const double seg_origin = path[seg_idx - 1];
const double seg_denom = to_price - seg_origin;
double fill_pos = seg_start;
if (std::abs(seg_denom) > kSegmentDenomEps) {
fill_pos += (fill.fill_price - seg_origin) / seg_denom;
}
fill.path_position = fill_pos;
return fill;
}

Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ set(TEST_SOURCES
test_drawing
test_margin_call
test_margin_call_intrabar_chronology
test_margin_call_trail_exit_chronology
test_margin_call_1x_long_entry_fill
test_percent_equity_open_entry_fee
test_streaming
Expand Down
35 changes: 22 additions & 13 deletions tests/test_direct_short_reversal_affordability.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
* An omitted-qty, percent-of-equity=100 MARKET LONG-to-SHORT reversal at
* margin_short=100 receives a fill-price affordability pass followed by one
* bounded adverse-high retry. Its position-lifecycle bit also participates in
* the established one-contract finite-price floor-zero fallback, while the
* optional full-residual interpretation keeps precedence.
* the established one-contract finite-price floor-zero fallback; the optional
* full-residual interpretation yields to that settled slice whenever the
* one-contract fallback is expressible (finding 279: TV slices and holds at
* eps-scale deficits, it never full-liquidates there).
*/

#include <array>
Expand Down Expand Up @@ -195,9 +197,9 @@ class FloorZeroPrecedenceProbe final : public DirectShortReversalProbe {
}
};

void test_direct_lifecycle_floor_zero_and_full_residual_precedence() {
void test_direct_lifecycle_floor_zero_full_residual_yields_to_slice() {
std::printf(
"test_direct_lifecycle_floor_zero_and_full_residual_precedence\n");
"test_direct_lifecycle_floor_zero_full_residual_yields_to_slice\n");
const std::vector<Bar> bars = {
bar(1000, 4506.71, 4506.71, 4506.71, 4506.71),
bar(2000, 4506.70, 4514.70, 4500.00, 4506.70),
Expand All @@ -222,6 +224,10 @@ void test_direct_lifecycle_floor_zero_and_full_residual_precedence() {
CHECK_NEAR(one_contract.position_size(), -1.7346, 1e-9);
CHECK(one_contract.direct_lifecycle_active());

// The full-residual opt-in yields to the settled one-contract slice at
// the floor-zero discontinuity: both cells now take the identical lots
// and HOLD the remainder (TV never full-liquidates at these eps-scale
// deficits — finding 279, serhan ADX).
FloorZeroPrecedenceProbe full_residual(/*full_residual=*/true);
full_residual.run(bars.data(), static_cast<int>(bars.size()));
const std::vector<double> full_residual_qty =
Expand All @@ -234,11 +240,11 @@ void test_direct_lifecycle_floor_zero_and_full_residual_precedence() {
&& full_residual_price.size() == 2U) {
CHECK_NEAR(full_residual_qty[0], 0.0392, 1e-9);
CHECK_NEAR(full_residual_price[0], 4514.70, 1e-9);
CHECK_NEAR(full_residual_qty[1], 2.7346, 1e-9);
CHECK_NEAR(full_residual_qty[1], 1.0, 1e-9);
CHECK_NEAR(full_residual_price[1], 4539.00, 1e-9);
}
CHECK_NEAR(full_residual.position_size(), 0.0, 1e-9);
CHECK(!full_residual.direct_lifecycle_active());
CHECK_NEAR(full_residual.position_size(), -1.7346, 1e-9);
CHECK(full_residual.direct_lifecycle_active());
}

class TrueFlatFullResidualControlProbe final
Expand Down Expand Up @@ -280,16 +286,19 @@ class TrueFlatFullResidualControlProbe final
}
};

void test_true_flat_full_residual_control_is_unchanged() {
std::printf("test_true_flat_full_residual_control_is_unchanged\n");
// Control: the commissioned true-flat lifecycle bit does not alter the
// floor-zero outcome — with the full-residual opt-in set, the settled
// one-contract slice still applies and the remainder is HELD.
void test_true_flat_floor_zero_control_slices_one_contract() {
std::printf("test_true_flat_floor_zero_control_slices_one_contract\n");
TrueFlatFullResidualControlProbe probe;
probe.trigger();
const std::vector<double> qty = probe.margin_quantities();
CHECK(qty.size() == 1U);
if (qty.size() == 1U) {
CHECK_NEAR(qty[0], 3.6930, 1e-9);
CHECK_NEAR(qty[0], 1.0, 1e-9);
}
CHECK_NEAR(probe.position_size(), 0.0, 1e-9);
CHECK_NEAR(probe.position_size(), -2.6930, 1e-9);
CHECK(!probe.direct_lifecycle_active());
}

Expand Down Expand Up @@ -683,8 +692,8 @@ void test_run_reset_clears_direct_lifecycle() {
int main() {
std::printf("--- direct short reversal affordability ---\n");
test_direct_reversal_runs_opening_check_and_one_adverse_retry();
test_direct_lifecycle_floor_zero_and_full_residual_precedence();
test_true_flat_full_residual_control_is_unchanged();
test_direct_lifecycle_floor_zero_full_residual_yields_to_slice();
test_true_flat_floor_zero_control_slices_one_contract();
test_direct_path_exclusion_matrix();
test_no_effect_same_side_add_preserves_direct_provenance();
test_fresh_entry_and_raw_order_clear_direct_provenance();
Expand Down
Loading
Loading