From 0aef9ebda653bf46a8b87da57bbc15e25775c374 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 1 Jul 2026 00:03:29 +0800 Subject: [PATCH] fix(ta): initialize recompute save-state in all indicator ctors (non-determinism UB) Indicator classes implement a save/restore mechanism for the engine's recompute() path (used by request.security HTF aggregation: a partial sub-bar issues recompute() -> restore()). Many constructors left their saved_* members UNINITIALIZED. When recompute() (-> restore()) is issued BEFORE the first compute() (-> which is the first call to save()), restore() reads indeterminate memory. This manifested on the 1m-magnifier + lookahead=barmerge.lookahead_on path: the security's first feed is a partial aggregated bar, so evaluate_security(..., is_complete=false) -> recompute() runs before any compute(). The live ta objects are freshly-constructed stack temporaries, so saved_* was uninitialized stack memory (not covered by value-init nor -ftrivial-auto-var-init). Garbage restored into the Wilder RMA poisoned RSI to NaN -> a DCA strategy produced 0 trades; a benign draw produced 125. The SAME binary flipped 0<->125 run-to-run. Initialize every saved_* member (ctor member-init or in-class initializer) to mirror the live member's initial value, so a recompute-before-compute restores a well-defined pristine state == compute() on bar 0. Byte-identical for every compute-first path (a preceding compute() always re-sets saved_* via save() before any restore() reads them) -> corpus unaffected. Classes fixed (23): RMA, RSI, EMA, Crossover, Crossunder, Cross, MFI, CMO, TSI, ATR, TR, Supertrend, DMI, SAR, Cum, AllTimeMax, AllTimeMin, BarsSince, OBV, AccDist, NVI, PVI, PVT, WAD, VWAP. (KC/ValueWhen and buffer-only classes that route recompute->compute when empty were already safe.) 3commas triple-rsi-dca on the 1m-magnifier path: was non-deterministic (0 or 125 trades); now stably 125 (29% TV match, price-exact 100%), same .so 12x -> one trade-CSV hash. Corpus held 251 excellent / 1 anomaly byte-identical; ctest 76/76 + test_recompute (102 checks): a poison-harness invariant asserting recompute-before-compute == compute-first for all 23 classes under 0xFF/0xAA/0x00 fill (reverting any ctor init makes it fail). No new C-ABI export. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/pineforge/ta.hpp | 36 +++- src/ta_extremes_volume.cpp | 17 +- src/ta_misc.cpp | 6 +- src/ta_moving_averages.cpp | 15 +- src/ta_oscillators.cpp | 43 ++++- src/ta_volatility_trend.cpp | 33 +++- tests/test_recompute.cpp | 367 ++++++++++++++++++++++++++++++++++++ 7 files changed, 489 insertions(+), 28 deletions(-) diff --git a/include/pineforge/ta.hpp b/include/pineforge/ta.hpp index 520a220..5f6ad52 100644 --- a/include/pineforge/ta.hpp +++ b/include/pineforge/ta.hpp @@ -800,8 +800,10 @@ class OBV { double prev_close_ = na(); int bar_count_ = 0; - double saved_sum_, saved_prev_close_; - int saved_bar_count_; + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + double saved_sum_ = 0.0, saved_prev_close_ = na(); + int saved_bar_count_ = 0; public: OBV() = default; @@ -812,7 +814,9 @@ class OBV { class AccDist { double sum_ = 0.0; - double saved_sum_; + // Mirror the initial committed sum_ (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + double saved_sum_ = 0.0; public: AccDist() = default; @@ -826,8 +830,11 @@ class NVI { double prev_volume_ = na(); int bar_count_ = 0; - double saved_nvi_, saved_prev_close_, saved_prev_volume_; - int saved_bar_count_; + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + double saved_nvi_ = 1.0, saved_prev_close_ = na(), + saved_prev_volume_ = na(); + int saved_bar_count_ = 0; public: NVI() = default; @@ -841,8 +848,11 @@ class PVI { double prev_volume_ = na(); int bar_count_ = 0; - double saved_pvi_, saved_prev_close_, saved_prev_volume_; - int saved_bar_count_; + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + double saved_pvi_ = 1.0, saved_prev_close_ = na(), + saved_prev_volume_ = na(); + int saved_bar_count_ = 0; public: PVI() = default; @@ -854,7 +864,9 @@ class PVT { double pvt_ = 0.0; double prev_close_ = na(); - double saved_pvt_, saved_prev_close_; + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + double saved_pvt_ = 0.0, saved_prev_close_ = na(); public: PVT() = default; @@ -866,7 +878,9 @@ class WAD { double wad_ = 0.0; double prev_close_ = na(); - double saved_wad_, saved_prev_close_; + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + double saved_wad_ = 0.0, saved_prev_close_ = na(); public: WAD() = default; @@ -908,7 +922,9 @@ class VWAP { // anchor (`anchor = timeframe.change("1D")`); engine matches that. int64_t anchor_day_ = std::numeric_limits::min(); - double saved_cum_pv_, saved_cum_vol_, saved_cum_pv_sq_; + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + double saved_cum_pv_ = 0.0, saved_cum_vol_ = 0.0, saved_cum_pv_sq_ = 0.0; int64_t saved_anchor_day_ = std::numeric_limits::min(); public: diff --git a/src/ta_extremes_volume.cpp b/src/ta_extremes_volume.cpp index 8836dd9..2c99395 100644 --- a/src/ta_extremes_volume.cpp +++ b/src/ta_extremes_volume.cpp @@ -170,7 +170,10 @@ double PivotLow::compute(double src) { // --- Cum (Cumulative Sum) --- -Cum::Cum() : sum_(0.0) {} +// saved_sum_ mirrors the initial committed sum_ (see RMA::RMA) so a +// recompute() before the first compute() restores a well-defined pristine +// state instead of reading uninitialized save-state. +Cum::Cum() : sum_(0.0), saved_sum_(0.0) {} double Cum::compute(double src) { saved_sum_ = sum_; @@ -183,7 +186,11 @@ double Cum::compute(double src) { // --- All-time max/min (chart series) --- -AllTimeMax::AllTimeMax() : max_(na()), has_(false) {} +// saved_* mirror the initial committed state (see RMA::RMA) so a recompute() +// before the first compute() restores a well-defined pristine state. +AllTimeMax::AllTimeMax() + : max_(na()), has_(false), + saved_max_(na()), saved_has_(false) {} double AllTimeMax::compute(double src) { saved_max_ = max_; @@ -200,7 +207,11 @@ double AllTimeMax::compute(double src) { return max_; } -AllTimeMin::AllTimeMin() : min_(na()), has_(false) {} +// saved_* mirror the initial committed state (see RMA::RMA) so a recompute() +// before the first compute() restores a well-defined pristine state. +AllTimeMin::AllTimeMin() + : min_(na()), has_(false), + saved_min_(na()), saved_has_(false) {} double AllTimeMin::compute(double src) { saved_min_ = min_; diff --git a/src/ta_misc.cpp b/src/ta_misc.cpp index 56ca5b6..79b4bc0 100644 --- a/src/ta_misc.cpp +++ b/src/ta_misc.cpp @@ -101,7 +101,11 @@ double PercentRank::compute(double src) { // BarsSince // ============================================================================ -BarsSince::BarsSince() : count_(0), ever_true_(false) {} +// saved_* mirror the initial committed state (see RMA::RMA) so a recompute() +// before the first compute() restores a well-defined pristine state. +BarsSince::BarsSince() + : count_(0), ever_true_(false), + saved_count_(0), saved_ever_true_(false) {} double BarsSince::compute(bool condition) { saved_count_ = count_; diff --git a/src/ta_moving_averages.cpp b/src/ta_moving_averages.cpp index 72fbb1d..f5623ac 100644 --- a/src/ta_moving_averages.cpp +++ b/src/ta_moving_averages.cpp @@ -20,7 +20,14 @@ namespace pineforge { namespace ta { RMA::RMA(int length) - : output_val(na()), sum(0.0), length(length), bar_count(0) {} + : output_val(na()), sum(0.0), length(length), bar_count(0), + // Mirror the initial committed state so a recompute() issued before + // the first compute() (e.g. the first partial sub-bar of a + // lookahead request.security aggregation) restores a well-defined + // pristine state instead of reading uninitialized save-state. Without + // this, restore() reads indeterminate memory and can poison the RMA + // with NaN non-deterministically. + saved_output_val_(na()), saved_sum_(0.0), saved_bar_count_(0) {} double RMA::compute(double src) { save(); @@ -111,7 +118,11 @@ double SMA::compute(double src) { EMA::EMA(int length) : output_val(na()), alpha(2.0 / (length + 1)), sum(0.0), - bar_count(0) {} + bar_count(0), + // 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()), saved_sum_(0.0), saved_bar_count_(0) {} double EMA::compute(double src) { save(); diff --git a/src/ta_oscillators.cpp b/src/ta_oscillators.cpp index 375c3c6..c56f657 100644 --- a/src/ta_oscillators.cpp +++ b/src/ta_oscillators.cpp @@ -23,7 +23,11 @@ namespace ta { // --- RSI --- RSI::RSI(int length) - : rma_up(length), rma_down(length), prev_src(na()), bar_count(0) {} + : rma_up(length), rma_down(length), prev_src(na()), bar_count(0), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state + // rather than reading uninitialized save-state members. + saved_prev_src_(na()), saved_bar_count_(0) {} double RSI::compute(double src) { saved_prev_src_ = prev_src; @@ -69,7 +73,10 @@ double RSI::compute(double src) { // Ref: pinets/src/namespaces/ta/methods/crossover.ts Crossover::Crossover() - : prev_a(na()), prev_b(na()) {} + : prev_a(na()), prev_b(na()), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_a_(na()), saved_prev_b_(na()) {} bool Crossover::compute(double a, double b) { saved_prev_a_ = prev_a; @@ -91,7 +98,10 @@ bool Crossover::compute(double a, double b) { // Ref: pinets/src/namespaces/ta/methods/crossunder.ts Crossunder::Crossunder() - : prev_a(na()), prev_b(na()) {} + : prev_a(na()), prev_b(na()), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_a_(na()), saved_prev_b_(na()) {} bool Crossunder::compute(double a, double b) { saved_prev_a_ = prev_a; @@ -159,7 +169,11 @@ double Change::compute(double src, int length) { Cross::Cross() : prev_a(na()), prev_b(na()), - last_nonzero_sign_(0) {} + last_nonzero_sign_(0), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_a_(na()), saved_prev_b_(na()), + saved_last_nonzero_sign_(0) {} bool Cross::compute(double a, double b) { saved_prev_a_ = prev_a; @@ -391,7 +405,13 @@ double RCI::compute(double src) { // MFI (Money Flow Index) // ============================================================================ -MFI::MFI(int length) : length_(length), prev_src_(na()), bar_count_(0) {} +MFI::MFI(int length) + : length_(length), prev_src_(na()), bar_count_(0), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + // The saved_*_buffer_ deques default-construct empty, matching the + // live pos_/neg_buffer_ initial empty state. + saved_prev_src_(na()), saved_bar_count_(0) {} double MFI::compute(double src, double vol) { saved_prev_src_ = prev_src_; @@ -421,7 +441,13 @@ double MFI::compute(double src, double vol) { // CMO (Chande Momentum Oscillator) // ============================================================================ -CMO::CMO(int length) : length_(length), prev_src_(na()), bar_count_(0) {} +CMO::CMO(int length) + : length_(length), prev_src_(na()), bar_count_(0), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + // The saved_*_buffer_ deques default-construct empty, matching the + // live up_/down_buffer_ initial empty state. + saved_prev_src_(na()), saved_bar_count_(0) {} double CMO::compute(double src) { saved_prev_src_ = prev_src_; @@ -455,7 +481,10 @@ double CMO::compute(double src) { TSI::TSI(int short_length, int long_length) : ema_long_(long_length), ema_short_(short_length), ema_abs_long_(long_length), ema_abs_short_(short_length), - prev_src_(na()), bar_count_(0) {} + prev_src_(na()), bar_count_(0), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_src_(na()), saved_bar_count_(0) {} double TSI::compute(double src) { saved_prev_src_ = prev_src_; diff --git a/src/ta_volatility_trend.cpp b/src/ta_volatility_trend.cpp index ba39921..bd839c2 100644 --- a/src/ta_volatility_trend.cpp +++ b/src/ta_volatility_trend.cpp @@ -53,7 +53,10 @@ MACDResult MACD::compute(double src) { // --- ATR --- ATR::ATR(int length) - : rma(length), prev_close(na()), bar_count(0) {} + : rma(length), prev_close(na()), bar_count(0), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_close_(na()), saved_bar_count_(0) {} double ATR::compute(double high, double low, double close) { saved_prev_close_ = prev_close; @@ -119,7 +122,12 @@ Supertrend::Supertrend(double factor, int atr_period) : factor_(factor), atr_(atr_period), prev_upper_(na()), prev_lower_(na()), prev_st_(na()), prev_direction_(1.0), - prev_close_(na()), initialized_(false) {} + prev_close_(na()), initialized_(false), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_upper_(na()), saved_prev_lower_(na()), + saved_prev_st_(na()), saved_prev_direction_(1.0), + saved_prev_close_(na()), saved_initialized_(false) {} SupertrendResult Supertrend::compute(double high, double low, double close) { saved_prev_upper_ = prev_upper_; @@ -216,7 +224,11 @@ DMI::DMI(int di_length, int adx_smoothing) : rma_plus_(di_length), rma_minus_(di_length), rma_tr_(di_length), rma_adx_(adx_smoothing), prev_high_(na()), prev_low_(na()), - prev_close_(na()), first_bar_(true) {} + prev_close_(na()), first_bar_(true), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_high_(na()), saved_prev_low_(na()), + saved_prev_close_(na()), saved_first_bar_(true) {} DMIResult DMI::compute(double high, double low, double close) { saved_prev_high_ = prev_high_; @@ -287,7 +299,14 @@ SAR::SAR(double start, double increment, double maximum) af_(0.0), ep_(0.0), sar_(0.0), is_long_(true), initialized_(false), prev_high_(na()), prev_low_(na()), prev_close_(na()), - prev_prev_high_(na()), prev_prev_low_(na()) {} + prev_prev_high_(na()), prev_prev_low_(na()), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_af_(0.0), saved_ep_(0.0), saved_sar_(0.0), + saved_is_long_(true), saved_initialized_(false), + saved_prev_high_(na()), saved_prev_low_(na()), + saved_prev_close_(na()), + saved_prev_prev_high_(na()), saved_prev_prev_low_(na()) {} namespace { @@ -514,7 +533,11 @@ double KCW::compute(double src, double high, double low, double close) { // TR (True Range as function) // ============================================================================ -TR::TR(bool handle_na) : prev_close_(na()), bar_count_(0), handle_na_(handle_na) {} +TR::TR(bool handle_na) + : prev_close_(na()), bar_count_(0), handle_na_(handle_na), + // Mirror the initial committed state (see RMA::RMA) so a recompute() + // before the first compute() restores a well-defined pristine state. + saved_prev_close_(na()), saved_bar_count_(0) {} double TR::compute(double high, double low, double close) { saved_prev_close_ = prev_close_; diff --git a/tests/test_recompute.cpp b/tests/test_recompute.cpp index b4c6b1c..93bed14 100644 --- a/tests/test_recompute.cpp +++ b/tests/test_recompute.cpp @@ -2,6 +2,9 @@ #include #include #include +#include +#include +#include #include using namespace pineforge; @@ -42,6 +45,85 @@ static const std::vector prices = { 102.0, 106.5, 97.5, 103.8, 100.5, 107.0, 98.0, 104.2, 101.8, 108.0 }; +// ============================================================================ +// Poison harness for the recompute-before-first-compute invariant. +// +// Construct an indicator into a heap buffer pre-filled with a fixed non-zero +// byte pattern. Any save-state (`saved_*`) member the constructor leaves +// UNINITIALIZED then reads as that deterministic garbage, so a recompute() +// issued before the first compute() (which calls restore() on indeterminate +// memory) misbehaves REPRODUCIBLY rather than flakily — giving the invariant +// test reliable teeth. Class-type members (deques, sub-indicators) are still +// default-constructed normally by the constructor; only uninitialized scalar +// save-state retains the poison. Once a class's ctor initializes every +// saved_* member (the fix), the poison is fully overwritten and the test +// passes under every pattern. +template +static T* poison_new(unsigned char pattern, Args&&... args) { + void* buf = ::operator new(sizeof(T)); + std::memset(buf, pattern, sizeof(T)); + if constexpr (sizeof...(Args) == 0) { + // DEFAULT-initialization (note: no parentheses). `new (buf) T()` would + // VALUE-initialize, which for a class with a defaulted (= default) + // default constructor zero-initializes the whole object FIRST — wiping + // the poison and hiding an uninitialized saved_* behind a lucky zero. + // `new (buf) T` runs only the constructor, so members the ctor does not + // initialize keep the poison. + return new (buf) T; + } else { + return new (buf) T(std::forward(args)...); + } +} +template +static void poison_delete(T* p) { + p->~T(); + ::operator delete(static_cast(p)); +} + +// Drive a recompute-FIRST series (bar 0 via recompute(), bars 1..K-1 via +// compute()) on a poisoned instance and compare bar-for-bar against a pure +// compute-first reference. rec(o,i)/com(o,i) call the class's recompute/compute +// with the per-bar args for bar i; both return values are coerced to double +// (bool auto-converts) for the na-aware eq() comparison. +template +static bool series_matches(unsigned char pat, Factory make, int K, Rec rec, Com com) { + auto* a = make(pat); // recompute-first (poisoned save-state on bar 0) + auto* b = make(pat); // reference: pure compute-first (never restores) + bool ok = eq((double)rec(a, 0), (double)com(b, 0)); + for (int i = 1; i < K; ++i) { + if (!eq((double)com(a, i), (double)com(b, i))) ok = false; + } + poison_delete(a); + poison_delete(b); + return ok; +} + +// Run the invariant under several poison patterns. A class whose ctor leaves +// any saved_* uninitialized diverges under at least one pattern; requiring ALL +// to hold maximizes the teeth. The patterns are complementary: +// 0xFF -> every saved_* double reads as NaN, reproducing the original +// NaN-poisoning failure mode and diverging for additive/accumulator +// state (a tiny-magnitude garbage double would be absorbed by FP +// addition and hide the bug); +// 0xAA -> a small finite garbage double, exercising threshold/sign paths; +// 0x00 -> zeros, which surface bugs that only appear when a garbage +// `first_bar_`/`initialized_` byte reads FALSE (so the indicator +// skips its first-bar branch). After the fix all patterns pass. +template +static bool all_patterns_match(Factory make, int K, Rec rec, Com com) { + bool ff = series_matches(0xFF, make, K, rec, com); + bool aa = series_matches(0xAA, make, K, rec, com); + bool zz = series_matches(0x00, make, K, rec, com); + return ff && aa && zz; +} + +static bool eq_st(const ta::SupertrendResult& x, const ta::SupertrendResult& y) { + return eq(x.value, y.value) && eq(x.direction, y.direction); +} +static bool eq_dmi(const ta::DMIResult& x, const ta::DMIResult& y) { + return eq(x.diplus, y.diplus) && eq(x.diminus, y.diminus) && eq(x.adx, y.adx); +} + // ============================================================================ // Test 1: SMA recompute // ============================================================================ @@ -136,6 +218,288 @@ void test_rsi_recompute() { printf("OK\n"); } +// ============================================================================ +// Regression: recompute() BEFORE the first compute(). +// +// In the bar-magnifier + lookahead request.security aggregation path, the +// security's first feed is a PARTIAL aggregated bar, which dispatches +// recompute() before any complete-bar compute() has run. recompute() calls +// restore(), which reads the save-state members (saved_*). If those are not +// initialized by the constructor they hold indeterminate memory, and the +// restore poisons the RMA/RSI with NaN non-deterministically — observed as a +// 0-vs-N trade-count flip across identical runs of the same binary. +// +// The fix (src/ta_moving_averages.cpp RMA::RMA, src/ta_oscillators.cpp +// RSI::RSI) initializes saved_* to mirror the initial committed state, so a +// recompute-first restores a well-defined pristine state and behaves exactly +// like compute-first. These tests lock that invariant: a recompute-first +// warmup must (a) equal the pure compute-first series bar-for-bar and (b) +// warm up to a finite (non-NaN) value rather than staying poisoned. +// ============================================================================ +void test_rma_recompute_before_first_compute() { + printf("Test RMA recompute before first compute... "); + const int len = 5; + ta::RMA a(len); // first bar arrives via recompute (partial sub-bar) + ta::RMA b(len); // reference: pure compute path + CHECK_EQ(a.recompute(prices[0]), b.compute(prices[0]), + "RMA recompute-first first bar == compute-first first bar"); + double ra = na(), rb = na(); + for (int i = 1; i < len + 3; i++) { + ra = a.compute(prices[i]); + rb = b.compute(prices[i]); + CHECK_EQ(ra, rb, "RMA committed series equal after recompute-first start"); + } + CHECK(!is_na(ra), "RMA warms up to a finite value (no NaN poisoning)"); + printf("OK\n"); +} + +void test_rsi_recompute_before_first_compute() { + printf("Test RSI recompute before first compute... "); + const int len = 5; + ta::RSI a(len); // first bar arrives via recompute (partial sub-bar) + ta::RSI b(len); // reference: pure compute path + CHECK_EQ(a.recompute(prices[0]), b.compute(prices[0]), + "RSI recompute-first first bar == compute-first first bar"); + double ra = na(), rb = na(); + for (int i = 1; i < len + 5; i++) { + ra = a.compute(prices[i]); + rb = b.compute(prices[i]); + CHECK_EQ(ra, rb, "RSI committed series equal after recompute-first start"); + } + CHECK(!is_na(ra), "RSI warms up to a finite value (no NaN poisoning)"); + printf("OK\n"); +} + +// ============================================================================ +// Class-wide invariant: recompute() BEFORE the first compute() must equal +// compute() on a fresh instance, for EVERY indicator with a save/restore +// (saved_*) mechanism. recompute() means "compute this bar as if the previous +// save-state were the committed state"; on a pristine object the previous +// committed state IS the constructor's initial state, so recompute-first must +// equal compute-first bar 0, and the committed series must stay identical +// thereafter. Each subtest poisons the save-state (see poison_new) so an +// uninitialized saved_* member fails DETERMINISTICALLY on the pre-fix tree. +// +// Classes whose save-state is entirely buffer/deque-based (Highest, SMA, WMA, +// StdDev, …) guard recompute() with `if (buffer.empty()) return compute(src)`, +// so they route to compute() automatically before the first bar and carry no +// scalar save-state to leak — they are covered by the existing recompute +// tests and need no poison check here. KC and ValueWhen already initialize +// their save-state (in-class initializer / empty-deque), and are safe. +// ============================================================================ +void test_all_classes_recompute_before_first_compute() { + printf("Test class-wide recompute-before-first-compute invariant...\n"); + + // Shared OHLCV-like fixtures (8 bars). + static const double H[] = {101.0, 103.0, 102.0, 105.0, 104.0, 107.0, 106.0, 109.0}; + static const double L[] = { 99.0, 100.0, 98.0, 101.0, 100.0, 103.0, 102.0, 105.0}; + static const double C[] = {100.0, 102.0, 99.0, 104.0, 101.0, 106.0, 103.0, 108.0}; + static const double Vl[] = {1000.0, 1500.0, 800.0, 2000.0, 1200.0, 1700.0, 900.0, 1300.0}; + static const int64_t TS[] = { + 1700000000000LL, 1700000060000LL, 1700000120000LL, 1700000180000LL, + 1700000240000LL, 1700000300000LL, 1700000360000LL, 1700000420000LL}; + + // --- EMA --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p, 5); }, 8, + [](ta::EMA* o, int i){ return o->recompute(prices[i]); }, + [](ta::EMA* o, int i){ return o->compute(prices[i]); }), + "EMA recompute-before-first-compute == compute-first"); + + // --- Crossover (bar 0: a>b so the prev-tie guard decides the result) --- + { + static const double A[] = {2.0, 1.0, 3.0, 1.5, 4.0, 0.5, 5.0, 2.0}; + static const double B[] = {1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0}; + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::Crossover* o, int i){ return o->recompute(A[i], B[i]); }, + [&](ta::Crossover* o, int i){ return o->compute(A[i], B[i]); }), + "Crossover recompute-before-first-compute == compute-first"); + } + + // --- Crossunder (bar 0: a(p); }, 8, + [&](ta::Crossunder* o, int i){ return o->recompute(A[i], B[i]); }, + [&](ta::Crossunder* o, int i){ return o->compute(A[i], B[i]); }), + "Crossunder recompute-before-first-compute == compute-first"); + } + + // --- Cross (bar 0: a!=b so the remembered-sign guard decides) --- + { + static const double A[] = {2.0, 1.0, 3.0, 1.0, 3.0, 1.0, 3.0, 1.0}; + static const double B[] = {1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0}; + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::Cross* o, int i){ return o->recompute(A[i], B[i]); }, + [&](ta::Cross* o, int i){ return o->compute(A[i], B[i]); }), + "Cross recompute-before-first-compute == compute-first"); + } + + // --- ATR --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p, 3); }, 8, + [&](ta::ATR* o, int i){ return o->recompute(H[i], L[i], C[i]); }, + [&](ta::ATR* o, int i){ return o->compute(H[i], L[i], C[i]); }), + "ATR recompute-before-first-compute == compute-first"); + + // --- TR --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p, false); }, 8, + [&](ta::TR* o, int i){ return o->recompute(H[i], L[i], C[i]); }, + [&](ta::TR* o, int i){ return o->compute(H[i], L[i], C[i]); }), + "TR recompute-before-first-compute == compute-first"); + + // --- MFI --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p, 3); }, 8, + [&](ta::MFI* o, int i){ return o->recompute(C[i], Vl[i]); }, + [&](ta::MFI* o, int i){ return o->compute(C[i], Vl[i]); }), + "MFI recompute-before-first-compute == compute-first"); + + // --- CMO --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p, 3); }, 8, + [](ta::CMO* o, int i){ return o->recompute(prices[i]); }, + [](ta::CMO* o, int i){ return o->compute(prices[i]); }), + "CMO recompute-before-first-compute == compute-first"); + + // --- TSI --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p, 3, 5); }, 8, + [](ta::TSI* o, int i){ return o->recompute(prices[i]); }, + [](ta::TSI* o, int i){ return o->compute(prices[i]); }), + "TSI recompute-before-first-compute == compute-first"); + + // --- Cum --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [](ta::Cum* o, int i){ return o->recompute(prices[i]); }, + [](ta::Cum* o, int i){ return o->compute(prices[i]); }), + "Cum recompute-before-first-compute == compute-first"); + + // --- AllTimeMax (bar 0 very negative so a poisoned has_=true/max_~0 leaks) --- + { + static const double X[] = {-1.0e6, 101.5, 99.8, 102.3, 98.5, 103.0, 101.2, 104.5}; + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::AllTimeMax* o, int i){ return o->recompute(X[i]); }, + [&](ta::AllTimeMax* o, int i){ return o->compute(X[i]); }), + "AllTimeMax recompute-before-first-compute == compute-first"); + } + + // --- AllTimeMin (bar 0 very positive) --- + { + static const double X[] = {1.0e6, 101.5, 99.8, 102.3, 98.5, 103.0, 101.2, 104.5}; + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::AllTimeMin* o, int i){ return o->recompute(X[i]); }, + [&](ta::AllTimeMin* o, int i){ return o->compute(X[i]); }), + "AllTimeMin recompute-before-first-compute == compute-first"); + } + + // --- BarsSince (bar 0 condition=false so a poisoned ever_true_ leaks) --- + { + static const bool COND[] = {false, false, true, false, false, true, false, false}; + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::BarsSince* o, int i){ return o->recompute(COND[i]); }, + [&](ta::BarsSince* o, int i){ return o->compute(COND[i]); }), + "BarsSince recompute-before-first-compute == compute-first"); + } + + // --- SAR --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p, 0.02, 0.02, 0.2); }, 8, + [&](ta::SAR* o, int i){ return o->recompute(H[i], L[i], C[i]); }, + [&](ta::SAR* o, int i){ return o->compute(H[i], L[i], C[i]); }), + "SAR recompute-before-first-compute == compute-first"); + + // --- OBV --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::OBV* o, int i){ return o->recompute(C[i], Vl[i]); }, + [&](ta::OBV* o, int i){ return o->compute(C[i], Vl[i]); }), + "OBV recompute-before-first-compute == compute-first"); + + // --- AccDist --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::AccDist* o, int i){ return o->recompute(H[i], L[i], C[i], Vl[i]); }, + [&](ta::AccDist* o, int i){ return o->compute(H[i], L[i], C[i], Vl[i]); }), + "AccDist recompute-before-first-compute == compute-first"); + + // --- NVI --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::NVI* o, int i){ return o->recompute(C[i], Vl[i]); }, + [&](ta::NVI* o, int i){ return o->compute(C[i], Vl[i]); }), + "NVI recompute-before-first-compute == compute-first"); + + // --- PVI --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::PVI* o, int i){ return o->recompute(C[i], Vl[i]); }, + [&](ta::PVI* o, int i){ return o->compute(C[i], Vl[i]); }), + "PVI recompute-before-first-compute == compute-first"); + + // --- PVT --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::PVT* o, int i){ return o->recompute(C[i], Vl[i]); }, + [&](ta::PVT* o, int i){ return o->compute(C[i], Vl[i]); }), + "PVT recompute-before-first-compute == compute-first"); + + // --- WAD --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::WAD* o, int i){ return o->recompute(H[i], L[i], C[i]); }, + [&](ta::WAD* o, int i){ return o->compute(H[i], L[i], C[i]); }), + "WAD recompute-before-first-compute == compute-first"); + + // --- VWAP --- + CHECK(all_patterns_match( + [](unsigned char p){ return poison_new(p); }, 8, + [&](ta::VWAP* o, int i){ return o->recompute(C[i], Vl[i], TS[i]); }, + [&](ta::VWAP* o, int i){ return o->compute(C[i], Vl[i], TS[i]); }), + "VWAP recompute-before-first-compute == compute-first"); + + // --- Supertrend (struct result: compare value + direction) --- + { + auto run = [&](unsigned char pat) { + auto* a = poison_new(pat, 3.0, 2); + auto* b = poison_new(pat, 3.0, 2); + bool ok = eq_st(a->recompute(H[0], L[0], C[0]), b->compute(H[0], L[0], C[0])); + for (int i = 1; i < 8; ++i) + if (!eq_st(a->compute(H[i], L[i], C[i]), b->compute(H[i], L[i], C[i]))) ok = false; + poison_delete(a); poison_delete(b); + return ok; + }; + CHECK(run(0xFF) && run(0xAA) && run(0x00), + "Supertrend recompute-before-first-compute == compute-first"); + } + + // --- DMI (struct result: compare diplus/diminus/adx) --- + { + auto run = [&](unsigned char pat) { + auto* a = poison_new(pat, 2, 2); + auto* b = poison_new(pat, 2, 2); + bool ok = eq_dmi(a->recompute(H[0], L[0], C[0]), b->compute(H[0], L[0], C[0])); + for (int i = 1; i < 8; ++i) + if (!eq_dmi(a->compute(H[i], L[i], C[i]), b->compute(H[i], L[i], C[i]))) ok = false; + poison_delete(a); poison_delete(b); + return ok; + }; + CHECK(run(0xFF) && run(0xAA) && run(0x00), + "DMI recompute-before-first-compute == compute-first"); + } + + printf(" ... done\n"); +} + // ============================================================================ // Test 5: Highest / Lowest recompute // ============================================================================ @@ -538,6 +902,9 @@ int main() { test_ema_recompute(); test_rma_recompute(); test_rsi_recompute(); + test_rma_recompute_before_first_compute(); + test_rsi_recompute_before_first_compute(); + test_all_classes_recompute_before_first_compute(); test_highest_lowest_recompute(); test_stddev_recompute(); test_macd_recompute();