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
144 changes: 115 additions & 29 deletions include/pineforge/engine.hpp

Large diffs are not rendered by default.

19 changes: 9 additions & 10 deletions include/pineforge/ta.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,17 @@ namespace ta {
// have accumulated, then seeds with the SMA of those first ``length`` values —
// unlike the documented ``pine_ema`` reference impl (and this engine's default),
// which seed ``ema := src`` on the first bar and are therefore never ``na``.
// The difference only bites a ``request.security`` HTF series read under a
// range-start-truncated feed (KI-55): the security's embedded ``ta.ema`` must
// be ``na`` for its whole warmup window to match TV, exactly as ``ta.rma`` /
// ``ta.sma`` already are.
// The difference matters for range-start-truncated chart and
// ``request.security`` series (KI-55): the relevant ``ta.ema`` must be ``na``
// for its whole warmup window to match TV, exactly as ``ta.rma`` / ``ta.sma``
// already are.
//
// An ``EMA`` instance latches this flag on its first ``compute()`` and keeps
// that mode for life. The engine raises the flag ONLY around
// ``request.security`` evaluation while the ``security_range_start_na_warmup``
// run flag is active (see BacktestEngine::feed_security_eval_state), so
// security-embedded EMAs latch ``true`` and chart-timeframe EMAs latch
// ``false``. When the run flag is never set the toggle stays ``false`` and
// every EMA is byte-identical to the prior src-seed behavior.
// that mode for life. The engine scopes the flag independently around chart
// ``on_bar`` dispatch (``chart_ema_na_warmup``) and request.security evaluation
// (``security_range_start_na_warmup``), so either context can opt in without
// contaminating the other. When neither run flag is set, every EMA is
// byte-identical to the prior src-seed behavior.
bool& ema_na_warmup_flag();

class RMA {
Expand Down
462 changes: 399 additions & 63 deletions src/engine_fills.cpp

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/engine_internal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar,
bool is_entry_bar,
bool magnifier_active,
double syminfo_mintick,
bool cascade_wp_gap = false);
bool cascade_wp_gap = false,
double path_start_position = 0.0);


// KI-67 exit cascade (Model S "R-cascade-gapjump"). Given the in-flight LEG
Expand Down
32 changes: 24 additions & 8 deletions src/engine_orders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ double BacktestEngine::fifo_drain(const std::string* from_entry, double qty_limi
pe.max_drawdown * keep_scale,
pe.skip_entry_bar_high,
pe.skip_entry_bar_low,
pe.market_pyramid_add});
pe.market_pyramid_add,
pe.entry_path_position});
}
}

Expand Down Expand Up @@ -255,16 +256,31 @@ void BacktestEngine::execute_partial_exit_by_entry(double fill_price, const std:
}


// Internal helper: partially close entries matching from_entry by qty_percent.
void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price,
const std::string& from_entry,
double qty_percent) {
// Internal helper: close an exact quantity only from entries matching
// from_entry. Live-position strategy.exit calls freeze their percent-derived
// reservations into PendingOrder::qty; when layered siblings fill on one bar,
// that absolute reservation must survive earlier reductions of the position.
void BacktestEngine::execute_partial_exit_by_entry_qty(
double fill_price, const std::string& from_entry, double qty_to_close) {
if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return;
if (!std::isfinite(qty_to_close) || qty_to_close <= kQtyEpsilon) return;

bool is_buy = (position_side_ == PositionSide::SHORT);
fill_price = apply_fill_slippage(fill_price, is_buy);
bool was_long = (position_side_ == PositionSide::LONG);

fifo_drain(&from_entry, qty_to_close, fill_price, was_long);
settle_position_after_partial_exit();
}


// Internal helper: resolve a genuinely deferred percentage at fill time, then
// close that quantity only from entries matching from_entry.
void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price,
const std::string& from_entry,
double qty_percent) {
if (position_side_ == PositionSide::FLAT || pyramid_entries_.empty()) return;

double matched_qty = 0.0;
for (const auto& pe : pyramid_entries_) {
if (pe.entry_id == from_entry) matched_qty += pe.qty;
Expand All @@ -275,9 +291,7 @@ void BacktestEngine::execute_partial_exit_by_entry_percent(double fill_price,
double qty_to_close = matched_qty * (pct / 100.0);
if (qty_to_close <= kQtyEpsilon) return;

// Close FIFO, but only from entries matching from_entry.
fifo_drain(&from_entry, qty_to_close, fill_price, was_long);
settle_position_after_partial_exit();
execute_partial_exit_by_entry_qty(fill_price, from_entry, qty_to_close);
}


Expand Down Expand Up @@ -555,6 +569,7 @@ void BacktestEngine::reset_position_state_to_flat() {
pyramid_entries_.clear();
id_unclosed_qty_.clear();
close_reserved_qty_.clear();
close_two_call_first_qty_.clear();
consumed_partial_exit_ids_.clear();
}

Expand Down Expand Up @@ -603,6 +618,7 @@ void BacktestEngine::open_fresh_position(PositionSide requested, double fill_pri
pyramid_entries_.clear();
id_unclosed_qty_.clear();
close_reserved_qty_.clear();
close_two_call_first_qty_.clear();
consumed_partial_exit_ids_.clear();
pyramid_entries_.push_back({fill_price, current_bar_.timestamp, qty, id, bar_index_});
id_unclosed_qty_[id] += qty;
Expand Down
28 changes: 24 additions & 4 deletions src/engine_path_resolve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,11 @@ ExitTrailState compute_exit_trail_state(bool is_long, double trail_points,
// Absolute activation price level (no entry-relative tick rounding).
s.activation_level = trail_price;
}
if (!std::isnan(trail_offset)) {
// TV treats an explicit zero offset like an omitted offset: the exit fires
// at the activation crossing itself. Keep zero represented as NaN here so
// it follows the existing activation-only path; positive offsets retain
// the ordinary best-price-minus/plus-offset trailing behaviour.
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)) {
Expand Down Expand Up @@ -796,7 +800,8 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar,
bool is_entry_bar,
bool magnifier_active,
double syminfo_mintick,
bool cascade_wp_gap) {
bool cascade_wp_gap,
double path_start_position) {
ExitPathFill fill;
if (position_side == PositionSide::FLAT) return fill;

Expand All @@ -823,7 +828,9 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar,
// the exit level lies in the in-flight remainder in the trigger direction, so
// the order gap-fills at that waypoint price — force the open-gap shortcut on
// even though entry and exit share this bar (is_entry_bar).
if (!is_entry_bar || magnifier_active || cascade_wp_gap) {
const double path_cursor = std::clamp(path_start_position, 0.0, 3.0);
if (path_cursor <= kPathPosEps
&& (!is_entry_bar || magnifier_active || cascade_wp_gap)) {
if (try_exit_open_gap_fill(bar, is_long, has_stop, stop_price,
has_limit, limit_price, trail, &fill)) {
return fill;
Expand All @@ -834,8 +841,21 @@ ExitPathFill resolve_exit_path_fill(const Bar& bar,
fill_bar_path_points(bar, path);

for (int seg_idx = 1; seg_idx < 4; ++seg_idx) {
const double from_price = path[seg_idx - 1];
// A priced parent entry can activate a resting from_entry bracket in
// the middle of this path. Skip every elapsed segment and truncate
// the containing segment to the unconsumed suffix. A waypoint cursor
// resumes on the following segment under that segment's normal
// direction-specific stop/limit rules.
if (path_cursor >= static_cast<double>(seg_idx) - kPathPosEps) {
continue;
}
double from_price = path[seg_idx - 1];
const double to_price = path[seg_idx];
const double seg_start = static_cast<double>(seg_idx - 1);
if (path_cursor > seg_start + kPathPosEps) {
const double t = std::clamp(path_cursor - seg_start, 0.0, 1.0);
from_price += (to_price - from_price) * t;
}
const bool rising = to_price > from_price;
const bool falling = to_price < from_price;

Expand Down
37 changes: 32 additions & 5 deletions src/engine_run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

#include "engine_internal.hpp"

#include <pineforge/ta.hpp>

#include <algorithm>
#include <cctype>
#include <cmath>
Expand All @@ -16,6 +18,27 @@ using namespace internal;

// open_trade_* accessors moved to engine_trade_accessors.cpp.

// Invoke the generated chart strategy body under its own EMA warmup mode.
// The selector is thread-local because multiple engines can run concurrently;
// restoring the previous value (also during stack unwinding) prevents both
// cross-engine contamination and leakage between chart and request.security
// evaluation. The latter installs its own scope around every security
// evaluator dispatch and restores the prior thread-local value on return.
void BacktestEngine::invoke_chart_on_bar(const Bar& bar) {
struct ChartEmaNaWarmupScope {
bool previous;
explicit ChartEmaNaWarmupScope(bool enabled)
: previous(ta::ema_na_warmup_flag()) {
ta::ema_na_warmup_flag() = enabled;
}
~ChartEmaNaWarmupScope() {
ta::ema_na_warmup_flag() = previous;
}
} scope(chart_ema_na_warmup_);

on_bar(bar);
}

// Standard per-script-bar dispatch sequence, shared by the simple run() loop,
// run_simple_bar_loop, and the no-magnifier aggregation path. Operates on
// current_bar_ (already set by the caller).
Expand All @@ -41,13 +64,13 @@ void BacktestEngine::dispatch_bar() {
if (process_orders_on_close_) {
process_pending_orders(current_bar_); // step 1: old stop/limit
update_per_trade_extremes(); // step 2: update before strategy reads
on_bar(current_bar_); // step 3: strategy logic
invoke_chart_on_bar(current_bar_); // step 3: strategy logic
flush_same_bar_close(); // step 3b: surviving strategy.close fill
process_pending_orders(current_bar_); // step 4: new market orders
} else {
process_pending_orders(current_bar_);
update_per_trade_extremes();
on_bar(current_bar_);
invoke_chart_on_bar(current_bar_);
}
// TradingView forced-liquidation check, once per script bar after all order
// processing, using this bar's full adverse extreme (high/low).
Expand Down Expand Up @@ -144,7 +167,7 @@ uint64_t BacktestEngine::execute_coof_script_body(
coof_cursor_price_ = broker_cursor_price;
coof_direct_fill_events_remaining_ = direct_fill_event_budget;
const uint64_t before = broker_fill_event_seq_;
on_bar(current_bar_);
invoke_chart_on_bar(current_bar_);
if (process_orders_on_close_) {
// A same-bar close batch is a broker fill at the current monotonic
// cursor. At the ordinary close execution that cursor is C; during a
Expand Down Expand Up @@ -448,9 +471,13 @@ void BacktestEngine::reset_run_state() {
sb_close_bar_ = -1;
sb_close_calls_ = 0;
sb_close_first_id_.clear();
sb_close_first_target_ = 0.0;
sb_close_first_carry_valid_ = false;
sb_close_first_carry_qty_ = 0.0;
sb_close_id_.clear();
sb_close_comment_.clear();
close_reserved_qty_.clear();
close_two_call_first_qty_.clear();
fold_exit_path_extremes_ = false;
fold_exit_trail_peak_ = std::numeric_limits<double>::quiet_NaN();
last_exit_fill_was_trail_ = false;
Expand Down Expand Up @@ -704,7 +731,7 @@ void BacktestEngine::run_magnified_bar(const std::vector<Bar>& sub_bars, int64_t
// magnifier: the single sub-bar IS the script bar).
current_bar_.timestamp = script_bar_ts;
_push_source_series();
on_bar(current_bar_);
invoke_chart_on_bar(current_bar_);
flush_same_bar_close(); // surviving strategy.close fill
process_pending_orders(current_bar_);
}
Expand All @@ -718,7 +745,7 @@ void BacktestEngine::run_magnified_bar(const std::vector<Bar>& sub_bars, int64_t
// not the final sub-bar ts.
current_bar_.timestamp = script_bar_ts;
_push_source_series();
on_bar(current_bar_);
invoke_chart_on_bar(current_bar_);
}
}
}
Expand Down
Loading
Loading