diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index d4eb22e..a730913 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -160,7 +160,12 @@ struct PendingOrder { bool is_long; double limit_price; // NaN = not set double stop_price; // NaN = not set - double trail_points; // NaN = not set + double trail_points; // NaN = not set (entry-relative activation, in ticks) + // NaN = not set (absolute activation price level). Default-initialized so + // direct PendingOrder constructions that never assign it (entry/order + // orders, test fixtures) cannot read an indeterminate value through the + // trail predicates. + double trail_price = std::numeric_limits::quiet_NaN(); double trail_offset; // NaN = not set double qty; // NaN = use default sizing, else explicit qty int qty_type; // -1 = qty is fixed contracts, else QtyType override diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 836f979..987a03e 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -254,8 +254,8 @@ void BacktestEngine::sort_exit_siblings_by_path_fill(const Bar& bar) { }; bool a_full = qp(a) >= 100.0 - kFullPercentEps; bool b_full = qp(b) >= 100.0 - kFullPercentEps; - const bool a_trail = !std::isnan(a.trail_points); - const bool b_trail = !std::isnan(b.trail_points); + const bool a_trail = !std::isnan(a.trail_points) || !std::isnan(a.trail_price); + const bool b_trail = !std::isnan(b.trail_points) || !std::isnan(b.trail_price); if (a_trail || b_trail) { if (a_full != b_full) { return a_full; @@ -301,7 +301,7 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { auto fill_phase = [&](const PendingOrder& o) { bool has_stop = !std::isnan(o.stop_price); bool has_limit = !std::isnan(o.limit_price); - bool has_trail = !std::isnan(o.trail_points); + bool has_trail = !std::isnan(o.trail_points) || !std::isnan(o.trail_price); bool exit_style = order_is_exit_style(o, position_side_); if (o.type == OrderType::MARKET @@ -357,7 +357,7 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { if (o.type != OrderType::EXIT) return false; bool has_stop = !std::isnan(o.stop_price); bool has_limit = !std::isnan(o.limit_price); - bool has_trail = !std::isnan(o.trail_points); + bool has_trail = !std::isnan(o.trail_points) || !std::isnan(o.trail_price); if (has_stop || has_limit || has_trail) return false; double qp = std::isnan(o.qty_percent) ? 100.0 : o.qty_percent; return qp >= 100.0 - kFullPercentEps; @@ -509,7 +509,8 @@ void BacktestEngine::apply_filled_order_to_state( // the trade's bars, so the flag stays false for them. fold_exit_path_extremes_ = !std::isnan(order.stop_price) || !std::isnan(order.limit_price) - || !std::isnan(order.trail_points) || !std::isnan(order.trail_offset); + || !std::isnan(order.trail_points) || !std::isnan(order.trail_price) + || !std::isnan(order.trail_offset); // Route LIMIT-triggered fills onto the unslipped limit-or-better // price path (apply_fill_slippage). RAII guard scoped strictly to the // dispatch block below: the intraday-cap synthetic close further down @@ -972,7 +973,8 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( if (process_orders_on_close_ && order.created_bar == bar_index_) { bool has_price_condition = !std::isnan(order.stop_price) || !std::isnan(order.limit_price) - || !std::isnan(order.trail_points); + || !std::isnan(order.trail_points) + || !std::isnan(order.trail_price); if (has_price_condition) { return OrderEligibility::Skip; } @@ -1020,7 +1022,7 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( bool is_entry_bar = (exit_style && position_open_bar_ == bar_index_); if (is_entry_bar) { bool has_price = !std::isnan(order.stop_price) || !std::isnan(order.limit_price) - || !std::isnan(order.trail_points); + || !std::isnan(order.trail_points) || !std::isnan(order.trail_price); if (!has_price) { return OrderEligibility::Skip; // skip market exits on entry bar } @@ -1050,7 +1052,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( bool exit_style = order_is_exit_style(order, position_side_); bool has_stop = !std::isnan(order.stop_price); bool has_limit = !std::isnan(order.limit_price); - bool has_trail = !std::isnan(order.trail_points); + bool has_trail = !std::isnan(order.trail_points) || !std::isnan(order.trail_price); last_exit_fill_was_trail_ = false; @@ -1071,6 +1073,7 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( order.stop_price, order.limit_price, order.trail_points, + order.trail_price, order.trail_offset, position_entry_price_, trail_best_path_state, diff --git a/src/engine_internal.hpp b/src/engine_internal.hpp index 04719e6..18dbcd4 100644 --- a/src/engine_internal.hpp +++ b/src/engine_internal.hpp @@ -209,6 +209,7 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, double stop_price, double limit_price, double trail_points, + double trail_price, double trail_offset, double position_entry_price, double trail_best_start, diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index 0fda92f..b0247b2 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -518,19 +518,29 @@ struct ExitTrailState { // level to nearest mintick at fill time, which produced a 1-tick-toward-entry // bias on roughly 40% of community/scalping-strategy trades. ExitTrailState compute_exit_trail_state(bool is_long, double trail_points, + double trail_price, double trail_offset, double entry_price, double trail_best_start, double syminfo_mintick) { ExitTrailState s; - s.has_trail = !std::isnan(trail_points); + // A trail is armed either by trail_points (activation offset in ticks from + // the entry price) OR by trail_price (an absolute activation price level). + // Pine's strategy.exit accepts both; they describe the same activation + // level two different ways. trail_points takes precedence if both are set. + s.has_trail = !std::isnan(trail_points) || !std::isnan(trail_price); s.best_price = trail_best_start; if (!s.has_trail) { return s; } - const double trail_ticks = std::ceil(trail_points); - s.activation_level = is_long - ? (entry_price + trail_ticks * syminfo_mintick) - : (entry_price - trail_ticks * syminfo_mintick); + if (!std::isnan(trail_points)) { + const double trail_ticks = std::ceil(trail_points); + s.activation_level = is_long + ? (entry_price + trail_ticks * syminfo_mintick) + : (entry_price - trail_ticks * syminfo_mintick); + } else { + // Absolute activation price level (no entry-relative tick rounding). + s.activation_level = trail_price; + } if (!std::isnan(trail_offset)) { s.trail_offset_price = std::ceil(trail_offset) * syminfo_mintick; } @@ -698,7 +708,7 @@ double exit_order_earliest_path_metric_no_trail( if (order.type != OrderType::EXIT) { return std::numeric_limits::infinity(); } - if (!std::isnan(order.trail_points)) { + if (!std::isnan(order.trail_points) || !std::isnan(order.trail_price)) { return std::numeric_limits::infinity(); } @@ -751,6 +761,7 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, double stop_price, double limit_price, double trail_points, + double trail_price, double trail_offset, double position_entry_price, double trail_best_start, @@ -765,7 +776,7 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar, const bool has_limit = !std::isnan(limit_price); ExitTrailState trail = compute_exit_trail_state( - is_long, trail_points, trail_offset, position_entry_price, + is_long, trail_points, trail_price, trail_offset, position_entry_price, trail_best_start, syminfo_mintick); // Open-gap shortcut. The legacy code only ran this on non-entry bars, diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 6082075..059f35a 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -156,6 +156,7 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.from_entry = ""; order.is_long = is_long; order.trail_points = std::numeric_limits::quiet_NaN(); + order.trail_price = std::numeric_limits::quiet_NaN(); order.trail_offset = std::numeric_limits::quiet_NaN(); order.qty = qty; order.qty_type = qty_type; @@ -295,7 +296,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro qp = (clamped_qty / position_qty_) * 100.0; } bool is_partial = qp < 100.0 - kFullPercentEps; - bool has_trail_request = !std::isnan(trail_points); + bool has_trail_request = !std::isnan(trail_points) || !std::isnan(trail_price); // Re-issued explicitly partial exits with the same id are one-shot for a live position. if (is_partial && position_side_ != PositionSide::FLAT @@ -352,6 +353,7 @@ void BacktestEngine::strategy_exit(const std::string& id, const std::string& fro order.limit_price = limit_price; order.stop_price = stop_price; order.trail_points = trail_points; + order.trail_price = trail_price; order.trail_offset = trail_offset; order.qty = reserved_qty; order.qty_type = -1; @@ -410,6 +412,7 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double order.from_entry = ""; order.is_long = is_long; order.trail_points = std::numeric_limits::quiet_NaN(); + order.trail_price = std::numeric_limits::quiet_NaN(); order.trail_offset = std::numeric_limits::quiet_NaN(); order.qty = qty; order.qty_type = -1; @@ -633,6 +636,7 @@ void BacktestEngine::queue_deferred_close_order(const std::string& id, order.limit_price = std::numeric_limits::quiet_NaN(); order.stop_price = std::numeric_limits::quiet_NaN(); order.trail_points = std::numeric_limits::quiet_NaN(); + order.trail_price = std::numeric_limits::quiet_NaN(); order.trail_offset = std::numeric_limits::quiet_NaN(); if (closes_any_qty) { order.qty = std::numeric_limits::quiet_NaN(); diff --git a/tests/test_path_resolve_extra.cpp b/tests/test_path_resolve_extra.cpp index 0086726..e7627bd 100644 --- a/tests/test_path_resolve_extra.cpp +++ b/tests/test_path_resolve_extra.cpp @@ -230,7 +230,7 @@ static void test_resolve_exit_trail_fills() { Bar flat_bar = mk(100, 102, 98, 100); ExitPathFill flat = resolve_exit_path_fill( flat_bar, PositionSide::FLAT, /*stop=*/98, /*limit=*/102, - /*trail_points=*/kNaN, /*trail_offset=*/kNaN, /*entry=*/100, + /*trail_points=*/kNaN, /*trail_price=*/kNaN, /*trail_offset=*/kNaN, /*entry=*/100, /*best_start=*/kNaN, /*is_entry_bar=*/false, /*magnifier=*/false, kMintick); CHECK(flat.should_fill == false); @@ -246,7 +246,7 @@ static void test_resolve_exit_trail_fills() { Bar trail_long = mk(100.5, 102, 100, 100.2); ExitPathFill fl = resolve_exit_path_fill( trail_long, PositionSide::LONG, /*stop=*/kNaN, /*limit=*/kNaN, - /*trail_points=*/100, /*trail_offset=*/50, /*entry=*/100, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/50, /*entry=*/100, /*best_start=*/kNaN, /*is_entry_bar=*/false, /*magnifier=*/false, kMintick); CHECK(fl.should_fill == true); @@ -258,7 +258,7 @@ static void test_resolve_exit_trail_fills() { // activation = 101 is crossed on the rising L->H leg -> fill@101. ExitPathFill fl_nooff = resolve_exit_path_fill( trail_long, PositionSide::LONG, kNaN, kNaN, - /*trail_points=*/100, /*trail_offset=*/kNaN, /*entry=*/100, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/kNaN, /*entry=*/100, /*best_start=*/kNaN, false, false, kMintick); CHECK(fl_nooff.should_fill == true); CHECK(near(fl_nooff.fill_price, 101.0)); @@ -273,7 +273,7 @@ static void test_resolve_exit_trail_fills() { Bar trail_short = mk(99.5, 101.5, 98, 99.8); ExitPathFill fs = resolve_exit_path_fill( trail_short, PositionSide::SHORT, kNaN, kNaN, - /*trail_points=*/100, /*trail_offset=*/50, /*entry=*/100, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/50, /*entry=*/100, /*best_start=*/kNaN, false, false, kMintick); CHECK(fs.should_fill == true); CHECK(near(fs.fill_price, 98.5)); @@ -284,7 +284,7 @@ static void test_resolve_exit_trail_fills() { Bar gap_nooff = mk(102, 103, 101, 102); ExitPathFill g_nooff = resolve_exit_path_fill( gap_nooff, PositionSide::LONG, kNaN, kNaN, - /*trail_points=*/100, /*trail_offset=*/kNaN, /*entry=*/100, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/kNaN, /*entry=*/100, /*best_start=*/101.5, false, false, kMintick); CHECK(g_nooff.should_fill == true); CHECK(near(g_nooff.fill_price, 102.0)); @@ -295,10 +295,34 @@ static void test_resolve_exit_trail_fills() { Bar gap_off = mk(101, 101.5, 100, 100.5); ExitPathFill g_off = resolve_exit_path_fill( gap_off, PositionSide::LONG, kNaN, kNaN, - /*trail_points=*/100, /*trail_offset=*/50, /*entry=*/100, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/50, /*entry=*/100, /*best_start=*/102, false, false, kMintick); CHECK(g_off.should_fill == true); CHECK(near(g_off.fill_price, 101.0)); + + // SHORT trail armed by ABSOLUTE trail_price (not trail_points). Pine's + // strategy.exit(trail_price=..., trail_offset=...) arms the trail at the + // given absolute price level rather than an entry-relative tick offset. + // activation = trail_price = 99 (absolute); offset 50t = 0.5. + // Bar (99.5, 101.5, 98, 99.8): low-first path 99.5 -> 98 -> 101.5 -> 99.8. + // leg O->L falls to 98 <= 99 -> trail arms, best=98. + // leg L->H rises 98->101.5; trail level = best + offset = 98.5 -> fill@98.5. + ExitPathFill fs_tp = resolve_exit_path_fill( + trail_short, PositionSide::SHORT, kNaN, kNaN, + /*trail_points=*/kNaN, /*trail_price=*/99, /*trail_offset=*/50, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fs_tp.should_fill == true); + CHECK(near(fs_tp.fill_price, 98.5)); + + // LONG trail armed by absolute trail_price with no offset -> exits AT the + // activation price itself when the path crosses up through it. + // activation = trail_price = 101; rising L->H leg crosses 101 -> fill@101. + ExitPathFill fl_tp = resolve_exit_path_fill( + trail_long, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/kNaN, /*trail_price=*/101, /*trail_offset=*/kNaN, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fl_tp.should_fill == true); + CHECK(near(fl_tp.fill_price, 101.0)); } // ── exit_order_earliest_path_metric_no_trail: entry-bar wrong-side block ──