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
3 changes: 2 additions & 1 deletion include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1754,7 +1754,8 @@ class BacktestEngine {
int explicit_qty_type);
void flip_market_position_to(const std::string& id, bool is_long,
double fill_price, double explicit_qty,
int explicit_qty_type);
int explicit_qty_type,
bool close_only = false);
void sequential_same_tick_reversal_fill(const std::string& id, bool is_long,
double fill_price, double explicit_qty,
int explicit_qty_type);
Expand Down
41 changes: 30 additions & 11 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -956,19 +956,38 @@ void BacktestEngine::apply_entry_order_fill(PendingOrder& order, double fill_pri
int count_before = position_entry_count_;
size_t trades_before_entry = trades_.size();

// Flat-issued pending ENTRY stops/limits act as a bracket: when one
// side opens the position, a touch of the opposite pending ENTRY
// (still flat-issued) closes at the touch price (TradingView's
// List of trades shows an exit tied to the opposite bracket),
// not a full reversal-and-new-position. The bracket persists
// across bars: probes 80-87 confirm TV closes the position on a
// later bar when only one leg fired earlier and the opposite
// leg's stop is touched subsequently.
// A pending priced (stop/limit) ENTRY that reaches its trigger while an
// OPPOSITE position it did NOT open is live closes that position at the
// touch price WITHOUT opening a new position in its own direction — a
// deferred flip's reduce leg fires, its open leg is superseded. The open
// leg re-arms via the same-bar re-issue (same id) and can fill on a later
// bar at the modified level (or never), exactly matching TradingView's
// "List of trades": an exit tied to the order, no accompanying entry.
//
// The discriminator is the order's ``created_position_side`` (snapshotted
// at placement, engine_strategy_commands.cpp): it is a reduce-only flip iff
// the order was NOT placed during the cycle of the position it now
// reverses (created_position_side != the current, opposite position side):
// - created FLAT (the original bracket case, probes 80-87): a flat-issued
// opposite stop closes the position other-side stop opened.
// - created OPPOSITE (deferred-flip carry, pyramid-deferred-flip-close-
// all-01): the stop was armed during a prior position cycle, a same-dir
// position opened after it, and the stop later flips THAT. TV closes it
// and re-arms; the ungated engine wrongly opened the reversed leg at the
// stale level (25 phantom/early shorts on that probe).
// A SAME-cycle reverse (created_position_side == the reversed side — the
// stop was placed while already holding the position it flips) DOES open
// the new leg, so it is excluded by the created!=current test. Deferred-flip
// carry entries that fire from FLAT are untouched (position_side_==FLAT).
// Approximation (no ground truth): created_position_side == position_side_
// stands in for "placed in THIS position instance's cycle". A double flip
// (created LONG, position flips SHORT then LONG again with the order still
// pending) reads as same-cycle and opens — out of scope.
PositionSide entry_req = order.is_long ? PositionSide::LONG : PositionSide::SHORT;
bool close_only_opposite =
order.created_position_side == PositionSide::FLAT
&& position_side_ != PositionSide::FLAT
&& entry_req != position_side_;
position_side_ != PositionSide::FLAT
&& entry_req != position_side_
&& order.created_position_side != position_side_;
execute_market_entry(order.id, order.is_long, fill_price, order.qty, order.qty_type,
order.created_position_side, close_only_opposite,
/*is_priced_entry=*/true,
Expand Down
21 changes: 19 additions & 2 deletions src/engine_orders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,12 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d
return;
}

flip_market_position_to(id, is_long, fill_price, explicit_qty, explicit_qty_type);
// ``close_only_opposite`` reaches here only for a created_position_side !=
// FLAT reduce-only flip (the FLAT bracket case returned above via
// close_opposite_then_enter): a deferred-flip carry that flips the opposite
// position without opening its own leg.
flip_market_position_to(id, is_long, fill_price, explicit_qty, explicit_qty_type,
/*close_only=*/close_only_opposite);
}


Expand Down Expand Up @@ -735,7 +740,8 @@ void BacktestEngine::close_opposite_then_enter(const std::string& id, bool is_lo
// remaining iterations.
void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long,
double fill_price, double explicit_qty,
int explicit_qty_type) {
int explicit_qty_type,
bool close_only) {
// For the close we need exit slippage based on closing direction.
// Closing a long = sell (price - slip); closing a short = buy (price + slip).
// fill_price already has entry slippage applied; un-slip it before
Expand All @@ -761,6 +767,17 @@ void BacktestEngine::flip_market_position_to(const std::string& id, bool is_long
emit_close_trade(pe, pe.qty, exit_fill, was_long);
}

if (close_only) {
// Deferred-flip carry reduce-only: this priced entry was armed during
// a prior position cycle and only flips the (later-opened) opposite
// position — its open leg is superseded by the same-id re-issue and
// re-arms at the modified level. Close the whole opposite position and
// stay flat; do NOT open. (See apply_entry_order_fill's
// close_only_opposite gate: created_position_side != position_side_.)
reset_position_state_to_flat();
return;
}

double new_qty = calc_qty_for_type(fill_price, explicit_qty, explicit_qty_type);
PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT;
open_fresh_position(requested, fill_price, new_qty, id);
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ set(TEST_SOURCES
test_multi_tier_exit_precedence
test_same_tick_multi_entry_race
test_full_close_while_pyramiding
test_deferred_flip_carry_close_only
test_lower_tf_parse_extra
test_ta_ma_warmup_extra
test_ta_osc_edge
Expand Down
197 changes: 197 additions & 0 deletions tests/test_deferred_flip_carry_close_only.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* test_deferred_flip_carry_close_only.cpp — regression for a deferred-flip
* carry priced ENTRY that flips the opposite position but must NOT open its
* own leg.
*
* Bug (pre-fix): a pending stop/limit ENTRY that reaches its trigger while an
* OPPOSITE position is live performs a FULL reversal (close the opposite,
* open the new direction) whenever ``created_position_side != FLAT``. The
* close-only bracket path (apply_entry_order_fill's ``close_only_opposite``)
* only fired for ``created_position_side == FLAT``. So a stop armed during a
* PRIOR position cycle — a same-id "S" stop placed while SHORT, that survives
* a flip to LONG and then triggers against that LONG — reopened a fresh SHORT
* at the (stale) stop level instead of just closing the long. TradingView
* closes the long and re-arms the entry (its open leg is superseded by the
* same-bar re-issue); the ungated engine emitted a phantom short.
* On corpus/validation/pyramid-deferred-flip-close-all-01 this was 25 phantom
* / one-bar-early shorts (countAbsDelta 22 → 2).
*
* Fix: the close_only_opposite gate is ``created_position_side != position_side_``
* (a reduce-only flip whenever the order was NOT placed in the cycle of the
* position it now reverses), and the created!=FLAT case routes through
* ``flip_market_position_to(..., close_only=true)`` which closes the whole
* opposite position and stays flat.
*
* A genuine SAME-cycle reverse (the stop was placed while already holding the
* position it flips: created_position_side == the reversed side) must STILL
* open the new leg — the second test guards that.
*
* Exemplar in the wild (covered by the corpus run, reproduced minimally here):
* pyramid-deferred-flip-close-all-01, the 2025-04-13 19:30 UTC phantom short —
* TV closes the long at the stale 1589.71 stop ("flip short stop") and opens
* NO short; the pre-fix engine opened a phantom short there.
*
* KNOWN APPROXIMATION (out of scope, no ground truth): the gate
* created_position_side != position_side_ approximates "the order predates
* this position instance". A double flip — created LONG, the position flips
* SHORT, then flips LONG again while the order is still pending — is
* misclassified as same-cycle (created LONG == current LONG) and would open.
* No export pins this case; left for a future rule-first cycle.
*/

#include <cmath>
#include <cstdio>
#include <limits>

#include <pineforge/engine.hpp>
#include <pineforge/bar.hpp>
#include <pineforge/na.hpp>

using namespace pineforge;

static int tests_passed = 0;
static int tests_failed = 0;

#define CHECK(expr) \
do { \
if (!(expr)) { \
std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++tests_failed; \
} else { \
++tests_passed; \
} \
} while (0)

static bool near(double a, double b, double tol = 1e-6) {
return std::fabs(a - b) <= tol;
}

static constexpr double kNaN = std::numeric_limits<double>::quiet_NaN();

static Bar mk(double o, double h, double l, double c, int64_t ts) {
Bar b;
b.open = o; b.high = h; b.low = l; b.close = c;
b.volume = 1000.0; b.timestamp = ts;
return b;
}

// ─────────────────────────────────────────────────────────────────────
// Deferred-flip carry: a short stop "S" is armed while SHORT (so its
// created_position_side is SHORT), the position then flips to LONG via a
// market entry, and "S" survives and triggers against that LONG. TV closes
// the long only; the engine must NOT open a phantom short.
//
// bar0: place market short "SH"
// bar1: SH fills @100 → SHORT 1; arm "S" short stop @95 (created SHORT)
// bar2: place market long "L"
// bar3: L fills @100 → reverses to LONG 1 (SH closed @100). "S"@95 pending,
// still carrying created_position_side = SHORT.
// bar4: low 94 ≤ 95 → "S" triggers while LONG. created(SHORT) != LONG →
// reduce-only flip: close the long @95, stay FLAT, open nothing.
//
// EXPECTED (fixed): flat at end; two closed trades (SH round-trip @100/100,
// L round-trip @100/95). Pre-fix: "S" opens a phantom short @95 → position
// ends SHORT (pos_size = -1) with an extra open leg.
// ─────────────────────────────────────────────────────────────────────
static void test_carry_stop_flips_opposite_close_only() {
std::printf("test_carry_stop_flips_opposite_close_only\n");
class Probe : public BacktestEngine {
public:
Probe() {
initial_capital_ = 1'000'000;
default_qty_type_ = QtyType::FIXED;
default_qty_value_ = 1.0;
slippage_ = 0;
commission_value_ = 0;
pyramiding_ = 1;
syminfo_mintick_ = 0.01;
}
double pos_size() const { return signed_position_size(); }
void on_bar(const Bar&) override {
if (bar_index_ == 0)
strategy_entry("SH", false, kNaN, kNaN, kNaN, "short setup");
if (bar_index_ == 1 && position_side_ == PositionSide::SHORT)
strategy_entry("S", false, kNaN, /*stop=*/95.0, kNaN, "carry short stop");
if (bar_index_ == 2)
strategy_entry("L", true, kNaN, kNaN, kNaN, "flip to long");
}
};
Probe p;
Bar bars[6] = {
mk(100, 100, 100, 100, 600'000), // bar0: place SH
mk(100, 100, 100, 100, 1'200'000), // bar1: SH fills @100; arm S@95
mk(100, 100, 100, 100, 1'800'000), // bar2: place L
mk(100, 100, 100, 100, 2'400'000), // bar3: L fills @100 → LONG 1
mk(100, 100, 94, 96, 3'000'000), // bar4: S@95 triggers vs LONG
mk( 96, 97, 95, 96, 3'600'000), // bar5: settle
};
p.run(bars, 6);

// Load-bearing: the carry stop closed the long WITHOUT opening a short.
CHECK(near(p.pos_size(), 0.0)); // pre-fix: -1 (phantom short open)

// Exactly two round trips: SH @100→@100, L @100→@95.
CHECK(p.trade_count() == 2);
if (p.trade_count() != 2) return;
const Trade& sh = p.get_trade(0);
const Trade& lt = p.get_trade(1);
CHECK(near(sh.entry_price, 100.0));
CHECK(near(sh.exit_price, 100.0));
CHECK(near(lt.entry_price, 100.0));
CHECK(near(lt.exit_price, 95.0)); // long closed at the stop level
CHECK(lt.exit_bar_index == 4);
}

// ─────────────────────────────────────────────────────────────────────
// Guard: a SAME-cycle reverse must STILL open the new leg. Here the short
// stop "S" is armed while already LONG (created_position_side == LONG), so it
// is a normal in-position flip: closing the long AND opening a short is
// correct (created == reversed side → close_only gate does NOT fire).
//
// bar0: place market long "L"
// bar1: L fills @100 → LONG 1; arm "S" short stop @95 (created LONG)
// bar4: low 94 ≤ 95 → "S" triggers: close long @95, open short 1 @95.
// EXPECTED: position ends SHORT 1 (a real flip, not close-only).
// ─────────────────────────────────────────────────────────────────────
static void test_same_cycle_reverse_still_opens() {
std::printf("test_same_cycle_reverse_still_opens\n");
class Probe : public BacktestEngine {
public:
Probe() {
initial_capital_ = 1'000'000;
default_qty_type_ = QtyType::FIXED;
default_qty_value_ = 1.0;
slippage_ = 0;
commission_value_ = 0;
pyramiding_ = 1;
syminfo_mintick_ = 0.01;
}
double pos_size() const { return signed_position_size(); }
void on_bar(const Bar&) override {
if (bar_index_ == 0)
strategy_entry("L", true, kNaN, kNaN, kNaN, "long setup");
if (bar_index_ == 1 && position_side_ == PositionSide::LONG)
strategy_entry("S", false, kNaN, /*stop=*/95.0, kNaN, "same-cycle short stop");
}
};
Probe p;
Bar bars[6] = {
mk(100, 100, 100, 100, 600'000), // bar0: place L
mk(100, 100, 100, 100, 1'200'000), // bar1: L fills @100; arm S@95 (created LONG)
mk(100, 100, 100, 100, 1'800'000),
mk(100, 100, 100, 100, 2'400'000),
mk(100, 100, 94, 96, 3'000'000), // bar4: S@95 triggers vs LONG
mk( 96, 97, 95, 96, 3'600'000),
};
p.run(bars, 6);

// A real flip: long closed, short opened. Position ends SHORT 1.
CHECK(near(p.pos_size(), -1.0));
}

int main() {
test_carry_stop_flips_opposite_close_only();
test_same_cycle_reverse_still_opens();
std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed);
return tests_failed == 0 ? 0 : 1;
}
Loading