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
7 changes: 6 additions & 1 deletion include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>::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
Expand Down
19 changes: 11 additions & 8 deletions src/engine_fills.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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;

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/engine_internal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 18 additions & 7 deletions src/engine_path_resolve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -698,7 +708,7 @@ double exit_order_earliest_path_metric_no_trail(
if (order.type != OrderType::EXIT) {
return std::numeric_limits<double>::infinity();
}
if (!std::isnan(order.trail_points)) {
if (!std::isnan(order.trail_points) || !std::isnan(order.trail_price)) {
return std::numeric_limits<double>::infinity();
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/engine_strategy_commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>::quiet_NaN();
order.trail_price = std::numeric_limits<double>::quiet_NaN();
order.trail_offset = std::numeric_limits<double>::quiet_NaN();
order.qty = qty;
order.qty_type = qty_type;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<double>::quiet_NaN();
order.trail_price = std::numeric_limits<double>::quiet_NaN();
order.trail_offset = std::numeric_limits<double>::quiet_NaN();
order.qty = qty;
order.qty_type = -1;
Expand Down Expand Up @@ -633,6 +636,7 @@ void BacktestEngine::queue_deferred_close_order(const std::string& id,
order.limit_price = std::numeric_limits<double>::quiet_NaN();
order.stop_price = std::numeric_limits<double>::quiet_NaN();
order.trail_points = std::numeric_limits<double>::quiet_NaN();
order.trail_price = std::numeric_limits<double>::quiet_NaN();
order.trail_offset = std::numeric_limits<double>::quiet_NaN();
if (closes_any_qty) {
order.qty = std::numeric_limits<double>::quiet_NaN();
Expand Down
36 changes: 30 additions & 6 deletions tests/test_path_resolve_extra.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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 ──
Expand Down
Loading