diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index c5c6d5c..07e88d6 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -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); diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index c66ed43..42a4d65 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -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, diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index b280940..7bf9826 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -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); } @@ -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 @@ -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); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index baa4676..5eea755 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/test_deferred_flip_carry_close_only.cpp b/tests/test_deferred_flip_carry_close_only.cpp new file mode 100644 index 0000000..7956712 --- /dev/null +++ b/tests/test_deferred_flip_carry_close_only.cpp @@ -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 +#include +#include + +#include +#include +#include + +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::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; +}