diff --git a/corpus b/corpus index 256dc79..b5d53d2 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit 256dc793ef66a008aba19f166f777f9475b664a6 +Subproject commit b5d53d2903e06f56dac88eb8eecef33465eb80ad diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index db09f70..edfa6d3 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -567,11 +567,42 @@ ExitTrailState compute_exit_trail_state(bool is_long, double trail_points, if (!std::isnan(trail_offset) && trail_offset != 0.0) { s.trail_offset_price = std::ceil(trail_offset) * syminfo_mintick; } - if (!std::isnan(s.best_price)) { + s.exits_at_activation = std::isnan(s.trail_offset_price); + // An EXPLICIT trail_offset=0 is TV's one-shot exit-at-activation trail: + // it FILLS at the activation crossing itself, so it can never survive + // into a later bar in the armed state — an armed zero-offset trail is an + // executed one. Deriving "armed" for it from the running post-entry + // extreme RETRO-ARMED the stop whenever an activation level refreshed + // from a newer close (strategy.exit re-issued per bar, e.g. + // trail_points = close * perc / syminfo.mintick, trail_offset = 0) + // dropped under a peak set beneath an older, higher level; the phantom + // stop then exited at the next bar's open (or at the stale level on an + // against-direction segment) — an exit TradingView never prints. TV rule + // (fitted 219/219 + 147/147 clean trailing exits on the boztilkiserhan + // WMA scalp/ADX tapes, both of which pass trail_offset=0 explicitly): + // level_t = entry +- prevBarClose*perc, live from the bar after entry, + // intrabar cross fills AT the level, at the open when the bar already + // opens past it. Both fill routes stay reachable below without any + // pre-arming (open-gap shortcut + with-direction segment cross), so + // only the retro-arming path is removed for this shape. + // + // An OMITTED trail_offset keeps the carried-best arming even though it + // shares the exit-at-activation FILL rule: TV treats its activation as + // durable order state measured against the position's running extreme. + // Discriminator (corpus bracket-exit-stop-limit-trail-same-bar-01, + // 2025-08-30 08:45): entry 4392.08, activation 4392.26 from + // trail_points=atr with NO offset argument, entry-bar high 4396.01 + // crossed the level before the order's first live bar, whose open + // 4392.25 sits one tick BELOW the level — TV fills at that open, which + // only a carried armed state can produce. Offset>0 trails likewise keep + // the reconstruction: activation is durable for every trail that keeps + // running after it activates. + const bool one_shot_zero_offset_trail = + !std::isnan(trail_offset) && trail_offset == 0.0; + if (!one_shot_zero_offset_trail && !std::isnan(s.best_price)) { s.trail_active = is_long ? (s.best_price >= s.activation_level) : (s.best_price <= s.activation_level); } - s.exits_at_activation = std::isnan(s.trail_offset_price); return s; } diff --git a/tests/test_fills_edge.cpp b/tests/test_fills_edge.cpp index e8dbcf8..d8c1e4c 100644 --- a/tests/test_fills_edge.cpp +++ b/tests/test_fills_edge.cpp @@ -783,6 +783,66 @@ static void test_trailing_activation_is_actionable_but_offset_only_is_inert() { CHECK(pending_exits_for(ExitActionForm::InfiniteTrailPoints) == 1); } +// ───────────────────────────────────────────────────────────────────── +// A per-bar re-issued exit-at-activation trail (trail_points refreshed from +// close, trail_offset = 0 — the boztilkiserhan serhan1 WMA scalp shape) must +// NOT retro-arm off the carried post-entry peak when a refreshed (lower) +// activation undercuts it. TV rule, fitted 219/219 + 147/147 clean trailing +// exits on those tapes: level_t = entry ± prevBarClose*perc, live from the +// bar after entry, fill AT the level on the intrabar cross (open if gapped). +// Bars below are the real 2025-04-09 discriminating tape: the engine used to +// exit at the 14:15 open 1475.99 while TV holds to 16:30 @1501.19. +// ───────────────────────────────────────────────────────────────────── +class ReissuedTrailRetroArmProbe : public BacktestEngine { +public: + ReissuedTrailRetroArmProbe() { + 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 exit_px(int i) const { return closed_trade_exit_price(i); } + int exit_bar_index(int i) const { return closed_trade_exit_bar_index(i); } + void on_bar(const Bar& bar) override { + if (bar_index_ == 0) + strategy_entry("Long", true, kNaN, kNaN, 1.0, "L"); + // Pine: if strategy.position_size > 0 -> strategy.exit(trail_points = + // close * trailPerc / syminfo.mintick, trail_offset = 0), every bar. + if (position_side_ == PositionSide::LONG) { + strategy_exit("Exit Long", "Long", kNaN, kNaN, + /*trail_points=*/bar.close * 0.015 / 0.01, + /*trail_offset=*/0.0); + } + } +}; + +static void test_reissued_trail_holds_to_tv_exit_no_retro_arm() { + std::printf("test_reissued_trail_holds_to_tv_exit_no_retro_arm\n"); + ReissuedTrailRetroArmProbe p; + Bar bars[5] = { + // signal bar; entry fills next open @1478.84 + {1478.84, 1478.84, 1478.84, 1478.84, 1000, kT0_UTC + 0 * k15m_ms}, + // entry bar; close 1486.70 -> next level ceil(2230.05)t = 1501.15 + {1478.84, 1487.00, 1475.00, 1486.70, 1000, kT0_UTC + 1 * k15m_ms}, + // "14:00": peak 1501.03 < level 1501.15 -> hold. close 1475.99 + // refreshes the level to ceil(2213.985)t = 1500.98 < carried peak. + {1486.69, 1501.03, 1473.27, 1475.99, 1000, kT0_UTC + 2 * k15m_ms}, + // "14:15": retro-arm bar. Nothing crosses 1500.98; the old code + // pre-armed off the 1501.03 peak and gap-filled at the open 1475.99. + // close 1489.89 -> level ceil(2234.835)t = 1501.19. + {1475.99, 1491.82, 1475.89, 1489.89, 1000, kT0_UTC + 3 * k15m_ms}, + // "16:30": high 1509.00 crosses 1501.19 -> TV exit AT the level. + {1489.88, 1509.00, 1488.10, 1497.10, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.exit_px(0), 1501.19)); + CHECK(p.exit_bar_index(0) == 4); + } +} + // strategy.close is still an ordinary deferred market close when POOC is off. class ExplicitMarketClose : public BacktestEngine { public: @@ -834,6 +894,7 @@ int main() { test_inert_exit_has_no_qty_or_oca_reservation(); test_absolute_and_relative_exit_forms_are_actionable(); test_trailing_activation_is_actionable_but_offset_only_is_inert(); + test_reissued_trail_holds_to_tv_exit_no_retro_arm(); test_strategy_close_market_behavior_remains(); std::printf("\n%d passed, %d failed\n", g_pass, g_fail); diff --git a/tests/test_path_resolve_extra.cpp b/tests/test_path_resolve_extra.cpp index 6f80a6a..7d28a46 100644 --- a/tests/test_path_resolve_extra.cpp +++ b/tests/test_path_resolve_extra.cpp @@ -313,9 +313,10 @@ static void test_resolve_exit_trail_fills() { CHECK(boz_long_zero.should_fill == true); CHECK(near(boz_long_zero.fill_price, 1918.59)); - // LONG trail no-offset, ALREADY armed via best_start, bar opens past the - // activation level -> gap-fill at the open (exits-at-activation gap arm). - // activation=101; best_start=101.5 (>=activation so armed); open=102>=101. + // LONG trail no-offset, bar opens PAST the activation level -> gap-fill + // at the open (exits-at-activation gap rule; no pre-arming involved — + // an exit-at-activation trail is never carried across bars armed). + // activation=101; open=102>=101. Bar gap_nooff = mk(102, 103, 101, 102); ExitPathFill g_nooff = resolve_exit_path_fill( gap_nooff, PositionSide::LONG, kNaN, kNaN, @@ -360,6 +361,115 @@ static void test_resolve_exit_trail_fills() { CHECK(near(fl_tp.fill_price, 101.0)); } +// ── explicit-zero-offset trails never retro-arm from the carried best ── +// +// An EXPLICIT trail_offset=0 trail FILLS at its activation crossing, so in +// TV it can never be carried into a later bar already armed. When the exit is +// re-issued each bar with trail_points derived from close (level_t = +// entry ± prevBarClose*perc), a refreshed activation can drop UNDER a peak +// that was set beneath an older, higher level; deriving "armed" from that +// carried peak retro-armed a phantom stop that gap-filled at the next bar's +// open. An OMITTED trail_offset keeps the carried arming (see the omitted +// control below — TV fills those at the open off the carried peak). +// Real discriminating bars from the boztilkiserhan-serhan1 WMA+RSI +// scalp tape (long entered 2025-04-09 00:15 @1478.84, trailPerc 1.5%, +// trail_offset passed as literal 0): +// +// 14:00 bar peaks at 1501.03 under that bar's level 1501.15 +// (trail_points = 1486.70*1.5/0.01 = 2230.05 -> ceil 2231t) — no cross. +// 14:15 order refreshes from close 1475.99 -> 2213.985 -> ceil 2214t +// -> activation 1500.98 < carried peak 1501.03. +// TV HOLDS through 16:30 (level from close 1489.89 -> 2235t -> 1501.19, +// crossed by high 1509.00, TV row px 1501.19). The engine used to +// retro-arm at the peak and exit at the 14:15 open 1475.99. +static void test_zero_offset_trail_never_retro_arms() { + std::printf("test_zero_offset_trail_never_retro_arms\n"); + + // 14:15 bar: nothing crosses 1500.98 intrabar and the open is BELOW the + // activation — the position must HOLD (previously: gap-fill at open). + Bar serhan_hold = mk(1475.99, 1491.82, 1475.89, 1486.23); + ExitPathFill retro = resolve_exit_path_fill( + serhan_hold, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/2213.985, /*trail_price=*/kNaN, + /*trail_offset=*/0.0, /*entry=*/1478.84, + /*best_start=*/1501.03, false, false, kMintick); + CHECK(retro.should_fill == false); + + // OMITTED offset control: the same bars with trail_offset = na keep the + // carried arming — the pre-armed level gap-fills at the open. This is + // the TV-pinned omitted-offset behavior (see the probe pin below); only + // the explicit literal 0 is the one-shot activation exit. + ExitPathFill retro_nan_off = resolve_exit_path_fill( + serhan_hold, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/2213.985, /*trail_price=*/kNaN, + /*trail_offset=*/kNaN, /*entry=*/1478.84, + /*best_start=*/1501.03, false, false, kMintick); + CHECK(retro_nan_off.should_fill == true); + CHECK(near(retro_nan_off.fill_price, 1475.99)); + + // TV pin for the OMITTED-offset carried arming, real corpus bars + // (bracket-exit-stop-limit-trail-same-bar-01, 2025-08-30 08:45 UTC): + // entry 4392.08, trail_points = atr(08:30) = 17.6225 -> ceil 18t -> + // activation 4392.26; the ENTRY bar's high 4396.01 crossed it before + // this first live bar, whose open 4392.25 is one tick BELOW the level. + // TV fills at that open — only a carried armed state produces this. + Bar probe_bar = mk(4392.25, 4399.90, 4385.55, 4393.34); + ExitPathFill probe_omitted = resolve_exit_path_fill( + probe_bar, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/17.6225, /*trail_price=*/kNaN, + /*trail_offset=*/kNaN, /*entry=*/4392.08, + /*best_start=*/4396.01, false, false, kMintick); + CHECK(probe_omitted.should_fill == true); + CHECK(near(probe_omitted.fill_price, 4392.25)); + + // ... and the TV exit: the 16:30 bar's refreshed level 1501.19 is crossed + // on the rising leg -> fill AT the level (the TV row price). + Bar serhan_tv_exit = mk(1489.88, 1509.00, 1488.10, 1497.10); + ExitPathFill tvx = resolve_exit_path_fill( + serhan_tv_exit, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/2234.835, /*trail_price=*/kNaN, + /*trail_offset=*/0.0, /*entry=*/1478.84, + /*best_start=*/1501.03, false, false, kMintick); + CHECK(tvx.should_fill == true); + CHECK(near(tvx.fill_price, 1501.19)); + + // SHORT dual: refreshed activation 99.02 rises ABOVE the carried trough + // 99.00; the bar opens above it and never falls to it -> HOLD + // (previously: retro-armed and gap-filled at the open 100.50). + Bar short_hold = mk(100.50, 100.80, 99.60, 100.10); + ExitPathFill s_retro = resolve_exit_path_fill( + short_hold, PositionSide::SHORT, kNaN, kNaN, + /*trail_points=*/98, /*trail_price=*/kNaN, + /*trail_offset=*/0.0, /*entry=*/100, + /*best_start=*/99.00, false, false, kMintick); + CHECK(s_retro.should_fill == false); + + // Control: the "open gapped past the activation" TV rule is untouched — + // no pre-arming is needed for it. activation 101, open 102 -> open fill. + Bar gap_past = mk(102, 103, 101.2, 102); + ExitPathFill gap = resolve_exit_path_fill( + gap_past, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_price=*/kNaN, + /*trail_offset=*/0.0, /*entry=*/100, + /*best_start=*/101.03, false, false, kMintick); + CHECK(gap.should_fill == true); + CHECK(near(gap.fill_price, 102.0)); + + // Control: an OFFSET trail keeps the carried-best arming — activation is + // durable order state for a trail that keeps running after activation. + // best_start=102 >= activation 101, offset 50t=0.5 -> level 101.5; + // open 101 <= 101.5 -> gap-fill at the open (same as the established + // gap_off pin inside test_resolve_exit_trail_fills). + Bar off_gap = mk(101, 101.5, 100, 100.5); + ExitPathFill off_armed = resolve_exit_path_fill( + off_gap, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_price=*/kNaN, + /*trail_offset=*/50, /*entry=*/100, + /*best_start=*/102, false, false, kMintick); + CHECK(off_armed.should_fill == true); + CHECK(near(off_armed.fill_price, 101.0)); +} + // ── exit_order_earliest_path_metric_no_trail: entry-bar wrong-side block ── // // On the entry bar a no-trail EXIT whose stop/limit lies on the wrong side of @@ -432,6 +542,7 @@ int main() { test_exit_order_touch_gap_arms(); test_path_cross_kind_priority_order(); test_resolve_exit_trail_fills(); + test_zero_offset_trail_never_retro_arms(); test_entry_bar_blocks_no_trail_exit(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1;