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
22 changes: 22 additions & 0 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,13 @@ class BacktestEngine {
Bar current_bar_;
int bar_index_ = 0;
int bar_index_offset_ = 0;
// Opt-in KI-55 HTF warmup parity (see set_syminfo_metadata,
// "security_range_start_na_warmup"). When enabled, request.security series
// aggregate from security_range_start_ms_ instead of the feed start and
// their embedded ta.ema na-warm per TV built-in semantics. Default OFF →
// byte-identical behavior; touched only through feed_security_eval_state.
bool security_range_start_na_warmup_ = false;
int64_t security_range_start_ms_ = 0;
int64_t next_order_seq_ = 1;
// TV: at most one priced ENTRY "open" event per bar; persists across
// multiple process_pending_orders calls (bar magnifier) and dual-pass
Expand Down Expand Up @@ -1908,6 +1915,21 @@ class BacktestEngine {
? static_cast<int>(std::llround(value))
: 0;
}
// Opt-in KI-55 HTF warmup parity. The value is the TV deep-backtest
// range start in epoch-ms (exactly representable as a double for any
// realistic date — 2025 is ~1.7e12 << 2^53). A positive, finite value
// enables it; anything else (0 / NaN / negative) is the disabled
// default, so a run that never sets this key is byte-identical.
if (key == "security_range_start_na_warmup") {
if (std::isfinite(value) && value > 0.0) {
security_range_start_na_warmup_ = true;
security_range_start_ms_ =
static_cast<int64_t>(std::llround(value));
} else {
security_range_start_na_warmup_ = false;
security_range_start_ms_ = 0;
}
}
// "qty_step" is the per-instrument lot increment used by the forced-
// liquidation quantizer. Route it onto the dedicated member so the
// codegen run(const Bar*, int) path (which never overwrites it) keeps
Expand Down
31 changes: 31 additions & 0 deletions include/pineforge/ta.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ namespace pineforge {

namespace ta {

// Opt-in TradingView-built-in EMA warmup toggle (thread-local, default false).
//
// TradingView's *built-in* ``ta.ema`` returns ``na`` until ``length`` values
// 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.
//
// 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.
bool& ema_na_warmup_flag();

class RMA {
double output_val;
double sum;
Expand Down Expand Up @@ -92,12 +112,23 @@ class EMA {
double alpha;
double sum;
int bar_count;
// Retained only for the opt-in TradingView-built-in na-warmup path (below),
// which must count `length` values before seeding with their SMA. The
// default src-seed recursion uses `alpha` alone and never reads this.
int length_;

// saved state for recompute
double saved_output_val_;
double saved_sum_;
int saved_bar_count_;

// TradingView-built-in warmup latch (see ema_na_warmup_flag above). Latched
// once on the first compute() and never revisited, so recompute()'s
// restore() (which only rewinds output_val/sum/bar_count) cannot flip the
// mode mid-series. Deliberately NOT part of save()/restore().
bool na_warmup_ = false;
bool warmup_latched_ = false;

public:
explicit EMA(int length);
double compute(double src);
Expand Down
26 changes: 26 additions & 0 deletions src/engine_security.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 Down Expand Up @@ -269,6 +271,30 @@ bool BacktestEngine::security_series_slot_is_new(int sec_id) const {


void BacktestEngine::feed_security_eval_state(SecurityEvalState& state, const Bar& input_bar) {
// Opt-in KI-55 HTF warmup parity (security_range_start_na_warmup run flag):
// (a) start every request.security aggregation at range_start_ms, not the
// feed start — drop pre-range input bars so the aggregator, its TA
// members, and the exposed history all begin at the range start;
// (b) its embedded lookback ta.ema na-warms per TV built-in semantics —
// scoped by raising ta::ema_na_warmup_flag() for the duration of this
// call, which covers every evaluate_security() dispatch below (each of
// which is the only place the security's EMA members compute());
// (c) plain security expressions (e.g. `close`) read na until the first
// COMPLETED HTF bar from the range start — a consequence of (a): under
// lookahead_off no evaluate_security() fires until the first bucket
// completes, and the partial first bucket counts as HTF bar 1.
// All three collapse to no-ops when the flag is unset (byte-identical).
if (security_range_start_na_warmup_
&& input_bar.timestamp < security_range_start_ms_) {
return;
}
struct SecurityNaWarmupScope {
bool prev_;
explicit SecurityNaWarmupScope(bool on)
: prev_(ta::ema_na_warmup_flag()) { ta::ema_na_warmup_flag() = on; }
~SecurityNaWarmupScope() { ta::ema_na_warmup_flag() = prev_; }
} _na_warmup_scope(security_range_start_na_warmup_);

if (state.lower_tf_use_input) {
// Buffer raw input bars until we accumulate one full script-TF
// chunk, then aggregate (if req > input) and dispatch each
Expand Down
43 changes: 42 additions & 1 deletion src/ta_moving_averages.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
namespace pineforge {
namespace ta {

// Thread-local so parallel in-process engines never cross-contaminate. Default
// false → src-seed EMA (byte-identical to prior behavior); the engine raises it
// only around request.security evaluation under the security_range_start_na_warmup
// run flag. See the declaration in <pineforge/ta.hpp> for the full rationale.
bool& ema_na_warmup_flag() {
static thread_local bool flag = false;
return flag;
}

RMA::RMA(int length)
: output_val(na<double>()), sum(0.0), length(length), bar_count(0),
// Mirror the initial committed state so a recompute() issued before
Expand Down Expand Up @@ -118,19 +127,51 @@ double SMA::compute(double src) {

EMA::EMA(int length)
: output_val(na<double>()), alpha(2.0 / (length + 1)), sum(0.0),
bar_count(0),
bar_count(0), length_(length),
// Mirror the initial committed state (see RMA::RMA) so a recompute()
// issued before the first compute() restores a well-defined pristine
// state instead of reading uninitialized save-state members.
saved_output_val_(na<double>()), saved_sum_(0.0), saved_bar_count_(0) {}

double EMA::compute(double src) {
save();
// Latch the warmup mode once, on the first compute(). A security-embedded
// EMA's first compute() always runs inside the engine's request.security
// evaluation (where the flag is raised under security_range_start_na_warmup),
// and a chart EMA's never does — so the latch is consistent for the
// instance's whole life without threading any per-instance wiring through
// codegen.
if (!warmup_latched_) {
na_warmup_ = ema_na_warmup_flag();
warmup_latched_ = true;
}
if (is_na(src)) {
// Pine semantics: ignore na inputs and keep prior EMA value.
return output_val;
}

if (na_warmup_) {
// TradingView *built-in* ta.ema warmup: return na until `length` values
// have accumulated since series start, then seed with the SMA of those
// first `length` values, then run the ordinary EMA recursion. Mirrors
// ta.rma/ta.sma warmup (RMA::compute above) so a range-start-truncated
// request.security(ta.ema(...)) reads na for its whole warmup window,
// matching TV (KI-55). Once output_val is non-na the series is seeded.
if (is_na(output_val)) {
sum += src;
bar_count++;
if (bar_count < length_) {
return na<double>();
}
// bar_count == length_: seed = SMA of the first `length_` values.
output_val = sum / static_cast<double>(length_);
return output_val;
}
output_val = alpha * src + (1.0 - alpha) * output_val;
bar_count++;
return output_val;
}

// Pine ta.ema reference:
// ema := na(ema[1]) ? src : alpha * src + (1 - alpha) * ema[1]
// Seed from the first non-na source value (not SMA warmup).
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ set(TEST_SOURCES
test_dmi_parity
test_integration
test_request_security
test_security_range_start_na_warmup
test_security_tf_validation
test_security_lower_tf_input_passthrough
test_ltf_buffer_no_leak
Expand Down
177 changes: 177 additions & 0 deletions tests/test_security_range_start_na_warmup.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// test_security_range_start_na_warmup — pins the opt-in KI-55 HTF warmup flag.
//
// The engine run flag ``security_range_start_na_warmup`` (carried through the
// existing syminfo_metadata channel as an epoch-ms range start) makes every
// ``request.security`` series:
// (a) start aggregation at the range start (drop pre-range input bars);
// (b) na-warm its embedded ``ta.ema`` per TradingView's *built-in* semantics
// — na until ``length`` HTF bars accumulate, then seed with the SMA of
// those first ``length`` values, then recurse (NOT the engine's default
// src-seed); and
// (c) expose plain security expressions (here the completed HTF close) only
// from the first COMPLETED HTF bar at/after the range start.
//
// This fixture drives one HTF ("60" aggregated from "15") request.security with
// an embedded ta.ema(close, 3) end-to-end through the engine and asserts the
// exact per-completed-bar sequence with the flag ON, then asserts the flag OFF
// run is byte-identical to the prior src-seed behavior (opt-in / no regression).
//
// It FAILS without the fix: without the flag machinery the ON run would drop
// nothing and src-seed the EMA, so both the trimmed-count and the na-warm-seed
// assertions below would fail.

#include <pineforge/engine.hpp>
#include <pineforge/ta.hpp>
#include <pineforge/na.hpp>

#include <cmath>
#include <cstdio>
#include <string>
#include <vector>

using namespace pineforge;

static int failures = 0;

// Bit-exact: the seed/recursion reference values are chosen to be exact
// doubles, so any drift (or a wrong warmup shape) is a hard mismatch.
static bool exact_eq(double a, double b) {
if (is_na(a) && is_na(b)) return true;
if (is_na(a) || is_na(b)) return false;
return a == b;
}

#define CHECK(cond, tag) do { \
if (!(cond)) { \
std::printf("FAIL: %s (line %d)\n", (tag), __LINE__); \
++failures; \
} \
} while (0)

// One HTF request.security ("60" from "15"), lookahead_off, with an embedded
// ta.ema(close, 3). Records every completed HTF bar's plain close and embedded
// EMA value, in order.
class RangeStartWarmupHarness : public BacktestEngine {
public:
ta::EMA sec_ema_{3};
std::vector<double> htf_close_seq;
std::vector<double> htf_ema_seq;

RangeStartWarmupHarness() {
register_security_eval(0, "60", "15", /*lookahead_on=*/false,
/*gaps_on=*/false);
}

void evaluate_security(int sec_id, const Bar& bar, bool is_complete) override {
if (sec_id != 0 || !is_complete) {
return;
}
htf_close_seq.push_back(bar.close);
htf_ema_seq.push_back(sec_ema_.compute(bar.close));
}

void on_bar(const Bar&) override {}
};

// 8 hours of 15m bars (4 bars/hour). Hour k's four bars all carry close
// hour_close[k], so the aggregated hourly close is unambiguously hour_close[k].
// Hour 0 (ts 0..2.7e6) lies BEFORE the range start (3.6e6) and is dropped when
// the flag is on. Hour 7 never completes (no following bar), matching the
// aggregator's boundary-flush rule.
static std::vector<Bar> make_feed() {
const double hour_close[8] = {999.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0};
std::vector<Bar> bars;
for (int h = 0; h < 8; ++h) {
for (int q = 0; q < 4; ++q) {
int64_t ts = static_cast<int64_t>(h) * 3600000
+ static_cast<int64_t>(q) * 900000;
double c = hour_close[h];
bars.push_back(Bar{c, c, c, c, 100.0, ts});
}
}
return bars;
}

// range start = start of hour 1 (drops hour 0).
static const double kRangeStartMs = 3600000.0;

void test_flag_on_na_warm_and_trim() {
RangeStartWarmupHarness strat;
strat.set_syminfo_metadata("security_range_start_na_warmup", kRangeStartMs);
auto bars = make_feed();
strat.run(bars.data(), static_cast<int>(bars.size()), "15", "15");

// (a)+(c): hour 0 trimmed -> completed HTF bars are hours 1..7 (7 of them;
// hour 7 is flushed as the final bucket at end-of-run). First completed
// close is hour 1's (10), NOT hour 0's (999).
const double exp_close[7] = {10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0};
CHECK(strat.htf_close_seq.size() == 7,
"flag-on: hour0 trimmed -> 7 completed HTF bars");
for (std::size_t i = 0; i < strat.htf_close_seq.size() && i < 7; ++i) {
CHECK(exact_eq(strat.htf_close_seq[i], exp_close[i]),
"flag-on: completed HTF close sequence");
}

// (b): ta.ema(3) na-warm. na, na, then SMA(10,20,30)=20 at the 3rd HTF bar,
// then EMA recursion with alpha = 2/(3+1) = 0.5:
// bar4: 0.5*40 + 0.5*20 = 30
// bar5: 0.5*50 + 0.5*30 = 40
// bar6: 0.5*60 + 0.5*40 = 50
// bar7: 0.5*70 + 0.5*50 = 60
const double na_ = na<double>();
const double exp_ema[7] = {na_, na_, 20.0, 30.0, 40.0, 50.0, 60.0};
CHECK(strat.htf_ema_seq.size() == 7, "flag-on: 7 embedded-EMA values");
for (std::size_t i = 0; i < strat.htf_ema_seq.size() && i < 7; ++i) {
CHECK(exact_eq(strat.htf_ema_seq[i], exp_ema[i]),
"flag-on: embedded ta.ema na-warm + SMA-first-length seed");
}

std::printf("test_flag_on_na_warm_and_trim: %s\n",
failures ? "FAIL" : "ok");
}

void test_flag_off_byte_identical_srcseed() {
int before = failures;
RangeStartWarmupHarness strat; // flag NOT set
auto bars = make_feed();
strat.run(bars.data(), static_cast<int>(bars.size()), "15", "15");

// No trim: hour 0 (close 999) is a valid HTF bar -> hours 0..7 complete (8;
// hour 7 flushed as the final bucket at end-of-run).
const double exp_close[8] = {999.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0};
CHECK(strat.htf_close_seq.size() == 8,
"flag-off: hour0 NOT trimmed -> 8 completed HTF bars");
for (std::size_t i = 0; i < strat.htf_close_seq.size() && i < 8; ++i) {
CHECK(exact_eq(strat.htf_close_seq[i], exp_close[i]),
"flag-off: completed HTF close sequence");
}

// Default src-seed EMA: never na; seeds at the first HTF close (999), then
// bar2: 0.5*10 + 0.5*999 = 504.5
// bar3: 0.5*20 + 0.5*504.5 = 262.25
const double exp_ema[4] = {
999.0, 504.5, 262.25,
0.5 * 30.0 + 0.5 * 262.25,
};
CHECK(strat.htf_ema_seq.size() == 8, "flag-off: 8 embedded-EMA values");
CHECK(!strat.htf_ema_seq.empty() && !is_na(strat.htf_ema_seq.front()),
"flag-off: EMA non-na from the first HTF bar (src-seed)");
for (std::size_t i = 0; i < strat.htf_ema_seq.size() && i < 4; ++i) {
CHECK(exact_eq(strat.htf_ema_seq[i], exp_ema[i]),
"flag-off: EMA src-seed recursion (byte-identical to prior)");
}

std::printf("test_flag_off_byte_identical_srcseed: %s\n",
(failures > before) ? "FAIL" : "ok");
}

int main() {
test_flag_on_na_warm_and_trim();
test_flag_off_byte_identical_srcseed();
if (failures) {
std::printf("%d check(s) FAILED\n", failures);
return 1;
}
std::printf("test_security_range_start_na_warmup passed.\n");
return 0;
}
Loading