From 9dfb362de1a810eb9c31e2e8adc0a465a6e21ae4 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 25 Jul 2026 15:25:50 +0800 Subject: [PATCH] fix(ta): return na, not an invented constant, on three degenerate arms ta.tsi, ta.cog and ta.cmo each guard a zero-denominator case by returning a hardcoded number (0.0) that is not derivable from the published Pine formula. The values were author's convenience, not derivations -- the sibling Stoch arm still carries the original comment ("Avoid division by zero; midpoint when flat") that shows how they were chosen. ta.wpr, whose degenerate condition is structurally identical, already returned na, so the engine was also internally inconsistent with itself. A clean-room TradingView oracle was exported and independently adjudicated on 2026-07-25 to settle what TradingView actually returns. It licenses exactly these three arms and no others: ta.tsi -> na the denominator is an EMA of |change|, which reaches exactly 0.0 only when the source is flat from bar 0, so the oracle's all-constant construction is not a special case -- it is the only reachable one. ta.cog -> na a zero window sum is structurally unreachable for positive prices, so the zero-sum construction likewise is the case. ta.cmo -> na TradingView does not guard this arm at all; it evaluates the formula, so 0/0 surfaces as na. The governing principle, which is what bounds this change: the engine's guard fires exactly when *the engine's own* denominator is bitwise 0.0, so the on-point oracle is one where *TradingView's* denominator is also bitwise 0.0. The all-constant construction guarantees that for these three arms. Deliberately NOT changed, because the same oracle refutes the obvious uniform fix for them: ta.stoch (50.0) known-wrong but the correct value is UNRESOLVED. na is wrong too: on a flat window of a varying source TradingView returned a number that is neither na nor 50. The only mechanism consistent with both probe families is carry-forward, which is inferred, not observed -- and shipping an inferred mechanism is what got a previous fix reverted. ta.cci (0.0) SPLIT. Exactly 0.0 on an all-constant source but measurably not 0 at a flat window of a varying source. The two surviving hypotheses demand opposite fixes, so na would be wrong under both. ta.mfi (100.0) UNVERIFIED. Agrees in the all-constant construction but is untested at a real flat window, and ta.cci is the standing proof that this construction can mislead. Tests, written RED first and covering compute() and recompute() for each arm (COG::recompute holds an independent copy of the guard; CMO/TSI delegate): - test_ta_osc_edge.cpp gains one degenerate case per fixed arm, each with a non-vacuous control so the assertion cannot pass by warmup, plus a NEGATIVE control pinning stoch/cci/mfi at their current constants so a well-meaning edit that "finishes the job" fails loudly. - test_ta_osc_edge.cpp's header claimed the expected values were "Pine-correct, derived by hand from the documented behaviour". The oracle falsifies that for four of the six arms; the claim is retracted and replaced with the per-arm evidence status. - test_ta_indicators_extras.cpp asserted the three old constants directly (test_cmo / test_tsi / test_cog). Those assertions were falsified by the oracle and are inverted here. Honest limit on impact: a prior audit measured every one of these arms as UNREACHABLE on the committed feed (0 bars with high == low; longest runs of identical values 2-4 against minimum lengths in use of 5-25), and TSI/COG are unreachable structurally at any timeframe. This is a spec-correctness fix with no expected measurable effect, and the corpus gate confirms it: all 254 engine_trades.csv tapes and validation_report.md are byte-for-byte identical before and after (252 ran, ok=252, excellent=247, strong=4, anomaly=1). ctest 121/121, unchanged from baseline. Not addressed here: ta.correlation carries the same defect shape (src/ta_misc.cpp, den == 0 -> 0.0, in both compute and recompute). The oracle carried no correlation leg, so it is out of scope and remains open. Co-Authored-By: Claude Opus 5 --- src/ta_oscillators.cpp | 27 +++- tests/test_ta_indicators_extras.cpp | 24 +++- tests/test_ta_osc_edge.cpp | 204 +++++++++++++++++++++++++++- 3 files changed, 243 insertions(+), 12 deletions(-) diff --git a/src/ta_oscillators.cpp b/src/ta_oscillators.cpp index bb5a409..b69d77f 100644 --- a/src/ta_oscillators.cpp +++ b/src/ta_oscillators.cpp @@ -480,7 +480,13 @@ double CMO::compute(double src) { for (auto v : up_buffer_) up_sum += v; for (auto v : down_buffer_) down_sum += v; double denom = up_sum + down_sum; - if (denom == 0) return 0.0; + // Degenerate window (no price movement at all): TradingView does NOT guard + // this arm — it evaluates the published formula, so 0/0 surfaces as na. + // Pinned by the 2026-07-25 clean-room oracle (CMO_NUM 0/21). The previous + // 0.0 was an invented constant, not a derivation. See the status block in + // tests/test_ta_osc_edge.cpp for the evidence and for why the sibling arms + // (stoch / cci / mfi) are deliberately NOT changed. + if (denom == 0) return na(); return 100.0 * (up_sum - down_sum) / denom; } @@ -509,7 +515,12 @@ double TSI::compute(double src) { prev_src_ = src; double ds = ema_short_.compute(ema_long_.compute(pc)); double ads = ema_abs_short_.compute(ema_abs_long_.compute(std::abs(pc))); - if (ads == 0) return 0.0; + // Degenerate denominator: the double-smoothed EMA of |change| reaches + // exactly 0.0 only when the source is flat from bar 0, so the oracle's + // all-constant construction is not a special case — it is the ONLY + // reachable one for this arm. TradingView returns na there (TSI_NUM 0/21, + // 2026-07-25 clean-room oracle). The previous 0.0 was invented. + if (ads == 0) return na(); // Pine v6 ta.tsi returns the True Strength Index normalised to [-1, 1] // (per the official reference manual). Classical TSI literature uses // [-100, 100] (i.e. ×100), but Pine deliberately drops the percentage @@ -557,7 +568,12 @@ double COG::compute(double src) { num += buffer_[n - 1 - i] * (i + 1); den += buffer_[i]; } - if (den == 0) return 0.0; + // Degenerate denominator: the window sum is exactly 0. Structurally + // unreachable for positive prices, so the oracle's zero-sum construction + // is the only case this arm ever sees, and TradingView returns na there + // (COG_NUM 0/21, 2026-07-25 clean-room oracle). The previous 0.0 was + // invented — note it is not even the formula's limit, which diverges. + if (den == 0) return na(); return -num / den; } @@ -781,7 +797,10 @@ double COG::recompute(double src) { num += buffer_[n - 1 - i] * (i + 1); den += buffer_[i]; } - if (den == 0) return 0.0; + // Mirror of compute()'s degenerate arm — see that function for the oracle + // citation. Kept in lock-step deliberately: this is an independent copy of + // the guard, not a delegation. + if (den == 0) return na(); return -num / den; } diff --git a/tests/test_ta_indicators_extras.cpp b/tests/test_ta_indicators_extras.cpp index 0311a3a..570c51b 100644 --- a/tests/test_ta_indicators_extras.cpp +++ b/tests/test_ta_indicators_extras.cpp @@ -262,10 +262,14 @@ static void test_cmo() { for (double p : {10.0, 11.0, 12.0, 13.0}) v = cmo.compute(p); CHECK(near(v, 100.0)); - // Constant series → both sums 0 → return 0.0 + // Degenerate window: a constant series makes up_sum + down_sum bitwise 0. + // TradingView does not guard this arm — it evaluates the formula, so the + // result is na. Pinned by the 2026-07-25 clean-room oracle (CMO_NUM 0/21); + // this assertion previously required the engine's invented 0.0. See the + // status block in tests/test_ta_osc_edge.cpp for the full evidence trail. ta::CMO flat(3); for (int i = 0; i < 4; ++i) flat.compute(5.0); - CHECK(near(flat.compute(5.0), 0.0)); + CHECK(is_na(flat.compute(5.0))); // recompute equals compute on same bar CHECK(near(cmo.recompute(13.0), 100.0)); @@ -286,11 +290,15 @@ static void test_tsi() { CHECK(v > 0.5); // monotone-up series should drive TSI well above zero CHECK(v <= 1.0); - // Zero divisor branch: constant series → ds == 0, ads == 0 → returns 0 + // Degenerate denominator: a constant series drives ads to bitwise 0, which + // is only reachable when the source is flat from bar 0. TradingView returns + // na there (TSI_NUM 0/21, 2026-07-25 clean-room oracle); this assertion + // previously required the engine's invented 0.0. See the status block in + // tests/test_ta_osc_edge.cpp for the full evidence trail. ta::TSI flat(3, 5); double last = std::numeric_limits::quiet_NaN(); for (int i = 0; i < 25; ++i) last = flat.compute(7.0); - CHECK(near(last, 0.0)); + CHECK(is_na(last)); // recompute returns same value as the most recent compute on same input double r = tsi.recompute(125.0); @@ -334,10 +342,14 @@ static void test_cog() { // correctness sweep against TV.) CHECK(near(v, -10.0 / 6.0)); - // den == 0 branch + // Degenerate denominator: a zero window sum, structurally unreachable for + // positive prices, so this construction is the only case the arm ever sees. + // TradingView returns na there (COG_NUM 0/21, 2026-07-25 clean-room + // oracle); this assertion previously required the engine's invented 0.0. + // See the status block in tests/test_ta_osc_edge.cpp for the evidence. ta::COG zero(3); zero.compute(0); zero.compute(0); - CHECK(near(zero.compute(0), 0.0)); + CHECK(is_na(zero.compute(0))); // recompute consistency CHECK(near(cog.recompute(3.0), -10.0 / 6.0)); diff --git a/tests/test_ta_osc_edge.cpp b/tests/test_ta_osc_edge.cpp index 45028b0..e1158fd 100644 --- a/tests/test_ta_osc_edge.cpp +++ b/tests/test_ta_osc_edge.cpp @@ -16,12 +16,70 @@ * ROC : na-input early return (227-229). * CCI : na-input early return (308-309). * RCI : na-input early return (346-348). + * CMO : degenerate up_sum+down_sum == 0 -> na. + * TSI : degenerate ads == 0 -> na. + * COG : degenerate window sum == 0 -> na (compute AND recompute). * * NDEBUG-PROOF: every assertion goes through CHECK(), which increments a * file-scope failure counter and is reported by main()'s nonzero return. * It does NOT use bare assert(), so it fires identically under -DNDEBUG. - * Expected numeric values are Pine-correct, derived by hand from the - * documented behaviour of ta_oscillators.cpp and pinned here. + * + * --------------------------------------------------------------------------- + * STATUS OF THE DEGENERATE-DENOMINATOR CONSTANTS (READ BEFORE "FINISHING THE + * JOB" ON THE REMAINING ARMS) + * + * An earlier revision of this header claimed the expected numeric values were + * "Pine-correct, derived by hand from the documented behaviour". For the + * degenerate (zero-denominator) arms of the oscillators that claim is FALSE + * and has been retracted: those constants were never derived, they were the + * original author's convenience values (see the in-code comment on the Stoch + * arm, "Avoid division by zero; midpoint when flat"). The Pine v6 reference + * publishes the formulas but is silent at the singularity. + * + * A clean-room TradingView oracle was exported and independently adjudicated + * on 2026-07-25 to settle them. Evidence (private campaign repo): + * data/probes/pf-probe-degenerate-oscillator-denominator/ (tape + source) + * .codex-evidence/degenerate-oscillator-oracle-20260725/ (frozen copy) + * data/progress/degenerate-arm-reachability-20260725.md (reachability) + * scratchpad/degenerate-oracle-adjudication-20260725.md (adjudication) + * + * What that oracle actually established, arm by arm: + * + * ta.tsi -> na ESTABLISHED, FIXED HERE. The all-constant construction is + * not a special case for this arm, it is the only reachable + * one (the denominator is an EMA of |change|, which reaches + * exactly 0 only when the source is flat from bar 0). + * ta.cog -> na ESTABLISHED, FIXED HERE. Same argument: a zero window sum + * is structurally unreachable for positive prices, so the + * zero-sum construction is the case. + * ta.cmo -> na ESTABLISHED, FIXED HERE. TradingView does not guard this + * arm at all; it evaluates the formula and yields na. + * + * ta.stoch = 50.0 KNOWN-WRONG, CORRECT VALUE UNRESOLVED — DO NOT "FIX". + * The oracle refutes 50.0 in both probe families, but it + * refutes na as well: on a flat window of a *varying* + * source TradingView returned a number that is neither na + * nor 50. The only mechanism consistent with both families + * is carry-forward, which is INFERRED, not observed. + * Shipping an inferred mechanism is the failure mode that + * got a previous fix reverted. The 50.0 assertion below is + * therefore retained deliberately: it pins current + * behaviour, it does NOT assert correctness. + * ta.cci = 0.0 SPLIT / UNDECIDED — DO NOT "FIX". Exactly 0.0 on an + * all-constant source (21/21) but measurably not 0 on a + * flat window of a varying source (0/17). The two surviving + * hypotheses demand OPPOSITE fixes, so na would be wrong + * under both. + * ta.mfi = 100.0 UNVERIFIED — DO NOT "FIX". Agrees with TradingView in the + * all-constant construction, but is untested at a real flat + * window; ta.cci is the standing proof that this exact + * construction can mislead. + * ta.wpr -> na already na; no pending change. + * + * test_degenerate_arms_negative_control() below pins stoch/cci/mfi at their + * current constants ON PURPOSE, so a well-meaning edit that extends the na fix + * to them fails loudly instead of silently shipping an unlicensed change. + * --------------------------------------------------------------------------- */ #include @@ -237,6 +295,144 @@ static void test_cci_rci_na() { CHECK(is_na(rci.compute(na()))); } +// ---------------------------------------------------------------------------- +// DEGENERATE-DENOMINATOR ARMS THAT THE 2026-07-25 TRADINGVIEW ORACLE SETTLED. +// +// TradingView returns `na` for all three; the engine used to return an invented +// constant (0.0). See the status block at the top of this file for the evidence +// paths and for why the *other* three arms are deliberately not touched. +// +// Each arm is asserted on compute() AND on recompute(). CMO::recompute and +// TSI::recompute restore their saved state and delegate to compute(), so the +// recompute assertion pins the delegation; COG::recompute carries its own +// independent copy of the guard, so its assertion pins a genuinely separate +// code path. +// ---------------------------------------------------------------------------- +static void test_cmo_degenerate_is_na() { + std::printf("test_cmo_degenerate_is_na\n"); + + // A source that never changes makes every up/down move exactly 0, so + // up_sum + down_sum is bitwise 0.0 and the degenerate arm fires. + // CMO(9) needs length+1 = 10 bars before it emits anything at all. + ta::CMO cmo(9); + double v = std::numeric_limits::quiet_NaN(); + for (int i = 0; i < 9; ++i) { + CHECK(is_na(cmo.compute(42.0))); // warmup: bar_count < length+1 + } + v = cmo.compute(42.0); // 10th bar: window full, denom == 0 + CHECK(is_na(v)); + // recompute() of the same degenerate bar must agree. + CHECK(is_na(cmo.recompute(42.0))); + + // Non-vacuous control: a genuinely one-sided window is NOT na and is NOT + // the degenerate constant. A strictly rising source gives down_sum == 0, + // so CMO == +100. + ta::CMO rising(9); + double r = std::numeric_limits::quiet_NaN(); + for (int i = 0; i < 10; ++i) r = rising.compute(100.0 + i); + CHECK(!is_na(r)); + CHECK(near(r, 100.0)); +} + +static void test_tsi_degenerate_is_na() { + std::printf("test_tsi_degenerate_is_na\n"); + + // ta.tsi's denominator is an EMA of |change|. It reaches exactly 0.0 only + // when the source is flat from bar 0 — which is precisely why the oracle's + // all-constant construction is the ONLY reachable case for this arm, not a + // special case. Feed well past both EMA warmups (long 25, then short 13). + ta::TSI tsi(13, 25); + double v = std::numeric_limits::quiet_NaN(); + for (int i = 0; i < 60; ++i) v = tsi.compute(1234.5); + CHECK(is_na(v)); + CHECK(is_na(tsi.recompute(1234.5))); + + // Non-vacuous control: a varying source produces a finite, non-degenerate + // TSI, so the assertion above is testing the guard and not warmup. + ta::TSI live(13, 25); + double w = std::numeric_limits::quiet_NaN(); + for (int i = 0; i < 60; ++i) w = live.compute(1000.0 + i * 3.0); + CHECK(!is_na(w)); +} + +static void test_cog_degenerate_is_na() { + std::printf("test_cog_degenerate_is_na\n"); + + // COG's denominator is the plain window sum. For positive prices it can + // never be 0, so the zero-sum window is the only case this arm ever sees. + // Signed window summing to bitwise 0.0 while the NUMERATOR stays non-zero, + // so this pins the guard specifically rather than a 0/0 coincidence: + // 1 + (-1) + 2 + (-2) == 0.0 exactly in IEEE-754. + ta::COG cog(4); + CHECK(is_na(cog.compute(1.0))); // warmup, buffer < length + CHECK(is_na(cog.compute(-1.0))); + CHECK(is_na(cog.compute(2.0))); + CHECK(is_na(cog.compute(-2.0))); // window full -> den == 0 + // COG::recompute carries its own copy of the guard (separate code path). + CHECK(is_na(cog.recompute(-2.0))); + + // All-zero window (the oracle's literal construction) also degenerates. + ta::COG zeros(10); + double z = std::numeric_limits::quiet_NaN(); + for (int i = 0; i < 10; ++i) z = zeros.compute(0.0); + CHECK(is_na(z)); + CHECK(is_na(zeros.recompute(0.0))); + + // Non-vacuous control: an ordinary positive window is finite, so the + // assertions above are testing the guard and not the warmup path. + // buffer [10,20,30]; Pine weights the NEWEST bar by 1 and the OLDEST by + // length -> num = 30*1 + 20*2 + 10*3 = 100, den = 60 -> -100/60. + ta::COG live(3); + live.compute(10.0); + live.compute(20.0); + double c = live.compute(30.0); + CHECK(!is_na(c)); + CHECK(near(c, -100.0 / 60.0)); + CHECK(near(live.recompute(30.0), -100.0 / 60.0)); +} + +// ---------------------------------------------------------------------------- +// NEGATIVE CONTROL — the three arms the oracle did NOT license a change for. +// +// These assertions pin CURRENT ENGINE BEHAVIOUR, not correctness. stoch's 50.0 +// is known-wrong with an unresolved correct value; cci's 0.0 is split between +// two hypotheses that demand opposite fixes; mfi's 100.0 is unverified at a +// real flat window. See the status block at the top of this file. +// +// If you are here because you extended the tsi/cog/cmo -> na fix to these arms +// and this test now fails: that is the test doing its job. Do not "fix" the +// test. Get an oracle first. +// ---------------------------------------------------------------------------- +static void test_degenerate_arms_negative_control() { + std::printf("test_degenerate_arms_negative_control\n"); + + // ta.stoch on a flat window -> 50.0 (NOT na). Known-wrong, unresolved. + ta::Stoch stoch(3); + stoch.compute(5.0, 5.0, 5.0); + stoch.compute(5.0, 5.0, 5.0); + CHECK(near(stoch.compute(5.0, 5.0, 5.0), 50.0)); + CHECK(near(stoch.recompute(5.0, 5.0, 5.0), 50.0)); + + // ta.cci on a constant source -> exactly 0.0 (NOT na). SPLIT/undecided: + // TradingView really does return bitwise 0.0 in this construction. + ta::CCI cci(3); + cci.compute(7.0); + cci.compute(7.0); + double c = cci.compute(7.0); + CHECK(!is_na(c)); + CHECK(near(c, 0.0)); + CHECK(near(cci.recompute(7.0), 0.0)); + + // ta.mfi with no negative money flow -> exactly 100.0 (NOT na). + // UNVERIFIED at a real flat window. MFI(5) needs length+1 = 6 bars. + ta::MFI mfi(5); + double m = std::numeric_limits::quiet_NaN(); + for (int i = 0; i < 6; ++i) m = mfi.compute(9.0, 100.0); + CHECK(!is_na(m)); + CHECK(near(m, 100.0)); + CHECK(near(mfi.recompute(9.0, 100.0), 100.0)); +} + int main() { test_rsi_na_input(); test_stoch_flat_and_na(); @@ -244,6 +440,10 @@ int main() { test_cross_na_and_false_branch(); test_mom_roc_na(); test_cci_rci_na(); + test_cmo_degenerate_is_na(); + test_tsi_degenerate_is_na(); + test_cog_degenerate_is_na(); + test_degenerate_arms_negative_control(); if (g_fail) { std::fprintf(stderr, "\nta_osc_edge: %d CHECK(s) FAILED\n", g_fail);