Skip to content

feat(vortex): Vortex Indicator, paired trend lines over summed true range (#349) - #377

Merged
mario4tier merged 1 commit into
TA-Lib:devfrom
kevinlincg:issue-349-vortex
Sep 5, 2026
Merged

feat(vortex): Vortex Indicator, paired trend lines over summed true range (#349)#377
mario4tier merged 1 commit into
TA-Lib:devfrom
kevinlincg:issue-349-vortex

Conversation

@kevinlincg

Copy link
Copy Markdown
Collaborator

Implements #349: VORTEX — +VI = SUM(|H − prevL|, n) / SUM(TR, n), −VI = SUM(|L − prevH|, n) / SUM(TR, n) (Botes & Siepman, TASC 28:1). Defaults 14, HLC bundle, two line outputs, flags: [stream] (the classifier folded the trailing-recompute shape on the first try — ULTOSC's two-lag precedent covered it), no unstable period. First output at index n, bar 0 consumed exactly as TA_TRANGE consumes it.

One deviation from the issue's decision 2, forced by a gate

The guard is not TA_IS_ZERO. The QUOTE-UNIT/SCALE gate rejects it: VORTEX is homogeneous of degree 0, so a power-of-two quote-unit change must be bit-identical — and the absolute 1e-14 band zeroes a legitimate ratio at 2^-120 (the gate fails exactly there, measured). The issue's ULTOSC citation predates #253; ULTOSC itself now counts flat bars. This body does the same: a per-bar trueRange == 0.0 test is exact and scale-invariant, and nullRun >= n recognizes the all-flat window with no absolute threshold. The all-flat behaviour is unchanged (both lines exactly 0.0), and both external implementations agree there.

Aliasing is the hand-written test's reason to exist

The loop writes its outputs last: the trailing subtraction re-reads bars trailingIdx−1 and trailingIdx — exactly at and one past the output slot — and TA-Lib permits aliasing any output onto any input. No generic gate exercises input == output, and all four backends inherit the order from the one input .c, so --xlang-hash would agree bitwise on a wrong answer. test_vortex.c aliases each of the 2 outputs onto each of the 3 inputs and bit-compares against the separate-buffer call.

Tests (test_vortex.c, registered in CMakeLists + Makefile.am + DO_TEST)

  • Differential, bit-exact (memcmp): fused vs TA_TRANGE + TA_SUM + hand abs-diff numerators, 6 periods × 6 startIdx, ≥4000-value floor. Two reference-side subtleties the grid surfaced: TA_SUM must be anchored at the fused loop's clamped start (its running sum accumulates rounding differently from a fresh prime — measured 1e-15-class drift at n=100 when rolled from term 0; same class of issue as COPPOCK's WMA re-anchor phase), and TA_SUM's period floor is 2, so n=1 uses the terms directly.
  • Formula: the issue's frozen triple-sourced goldens (pandas-ta-classic / numpy textbook / talib-primitives, mutually bit-identical) at rel 1e-12 — not bitwise, since pandas' rolling sum is Kahan-compensated and drifts ~1.5e-15 at n=100.
  • All-flat window: exact 0.0 on both lines; unguarded this is NaN, so the assertion is the guard's own non-vacuity (IMI: successful call emits NaN on an all-flat window (0/0) — guard the divide, return 50.0 #112 satisfied).
  • Edges: startIdx == endIdx (bit-equal to the full-range bar), lookback clamping, n=1.

Verification

Full --codegen (all four languages) rc 0 including the QUOTE-UNIT/SCALE leg that rejected the first guard; stream_verify green; --xlang-hash zero non-baseline mismatches (no transcendentals — fabs/comparisons only — so no CODEGEN_TRANSCENDENTAL[] entry); full generator suite + clippy + regen-check + check-source-lists clean. The ta4j / trading-signals oracle arms named by the issue live in the oracle infrastructure outside this repo; the goldens above carry the formula proof here. CHANGELOG entry added.

@mario4tier

Copy link
Copy Markdown
Member

Reviewed at de9321a8. One blocker and three doc issues.

Blocker — the zero-denominator guard never tests the denominator

vortex.c:165 gates only on the flat-bar counter:

if( nullRun >= optInTimePeriod ) { 0.0; 0.0; }
else { outPlusVI[outIdx] = curVMP / curTR; ... }

curTR is a running sum maintained by add-and-subtract, so nullRun is a proxy for sTR == 0 that holds only in exact arithmetic. Floating-point absorption breaks it.

Reproducer: 40 bars, all H=L=C=100.0, except bar 20 with H=356.0 (TR 256) and bar 21 with H=100.00000000000001 (TR ≈ 1.42e-14). Bar 21's true range is absorbed when added to 256; subtracting bar 20's 256 later leaves sTR exactly 0.0 while nullRun is only 13. At bar 34 the else arm runs: outPlusVI = 0.0/0.0 = NaN, outMinusVI = 256.0/0.0 = +Inf. Correct values are 1.0 and ~1.8e16.

That matters beyond the arithmetic: VORTEX does not set TA_FUNC_FLG_NAN_INF_OUT, and ta_abstract.h:205 says "ta_regtest holds every function WITHOUT this flag to finite output". The website page ships Can Output NaN or ±Inf: unchecked. All four backends inherit the shape, so --xlang-hash agrees on the wrong answer and no gate sees it.

Both precedents the comment cites do more than VORTEX does. ultosc.c:225-240 reseeds the accumulators to exactly 0.0 on nullRun >= period, and then still gates each division with if( b1Total > 0.0 ) at :248-250 — its own comment explains why: "the reseed above removes their residue, so the test is exact." cmou.c:114 likewise carries if( sum > 0.0 ).

You are right to reject TA_IS_ZERO(sTR) — a 1e-14 absolute band on a quote-unit quantity is exactly the #253 regression. The fix is the other half of the ULTOSC pattern: reseed on the flat run and guard the division with an exact curTR > 0.0, which is scale-invariant and costs nothing.

Documentation

  1. vortex.md:35 restates the YAML default. - optInTimePeriod - Number of bars in the rolling sums (default 14). The generator appends its own YAML-derived clause to that same string, so three backends ship a stutter: "(default 14) (default 14, range 1..=100000)" in rustdoc (vortex.rs:71,298), javadoc (Core_VORTEX.java:21,388,473) and the C# XML (Core_VORTEX.cs:66,441,532), plus the website table whose Default column already says 14. VORTEX is the only one of 190 input .md files that restates a default. Change the YAML to 21 and one sentence would state two different defaults in three languages.
  2. **vortex.md:11 authors a fence.** No other `.md` in the repo does. The Java and C# emitters already wrap the Formula section in a code block, so the fence nests inside it and ships as literal markers, swallowing the following prose paragraph — including a raw [ULTOSC](/functions/ultosc) link — into the same preformatted block.
  3. vortex.md has no ## Implementation section, the only one of 190 without. The generated page therefore ends at the Properties table with no source-link block and no wrapper pointer. Nothing regenerates it, so regen-check stays green and CI cannot see the omission.

Happy to push the fixes on top rather than send them back to you — say which you prefer.

@kevinlincg

Copy link
Copy Markdown
Collaborator Author

I'll fix — the reproducer is exactly right, and I only took half of ULTOSC's pattern (the count, not the reseed-plus-exact-division-gate). Fixes and the absorption case as a test leg incoming on this branch; ER (#350, in progress) gets the same full pattern.

@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Fixed at b24eda79c — took it myself, thanks for the offer.

Blocker: the guard is now ULTOSC's full pattern, both halves — the flat-run reseeds all three sums to exactly 0.0 (purging the add/subtract residue), and the division is gated on the denominator itself with an exact curTR > 0.0. Your absorption reproducer is a test leg now: 40 flat bars with the 256-spread at bar 20 and the 1-ULP spread at bar 21 — every output asserted finite, and bar 35 (the window that is exactly the absorbed 0.0) asserted exactly 0.0. Full --codegen rc 0 including QUOTE-UNIT/SCALE, --xlang-hash zero non-baseline, regen-check clean, all against this head.

Docs: the fence is flattened into prose (the Formula section now reads as sentences with inline code), the Parameters bullet no longer restates the default, and ## Implementation / ## Aliases / ## See Also / ## References follow the standard blocks.

The same lesson is already applied to ER (#350, in progress): its guard is current kama.c's #253 form — the issue's "verbatim kama.c" snippet predates that fix — with the same reseed-plus-exact-comparison shape.

@kevinlincg
kevinlincg force-pushed the issue-349-vortex branch 2 times, most recently from b24eda7 to c9d311b Compare September 5, 2026 05:46
@mario4tier

Copy link
Copy Markdown
Member

Blocker in the reseed — and the bad advice was mine. Apologies.

My earlier comment said to apply "the ULTOSC pattern: reseed on the flat run and guard the division with an exact curTR > 0.0". You implemented exactly that. I did not check ULTOSC's reseed predicate before recommending it, and that is where this goes wrong.

The defect

vortex.c:121 increments nullRun on trueRange == 0.0 alone, then :129-135 zeroes all three sums:

if( trueRange == 0.0 ) nullRun++; else nullRun = 0;
if( nullRun >= optInTimePeriod ) { sTR = 0.0; sVMP = 0.0; sVMM = 0.0; }

TR[t] == 0 forces H[t] == L[t] == C[t-1] — a statement about bar t. But

sVMP += fabs( inHigh[today] - inLow[today-1] );
sVMM += fabs( inLow[today]  - inHigh[today-1] );

read bar today-1. For the first bar of the flat run, today-1 lies outside the run and generally has a real range, so its VMP/VMM terms are live and non-zero. Zeroing them discards those terms; the trailing subtraction at the bottom of the same iteration then retires them a second time, so sVMP/sVMM go negative and stay corrupted for the rest of the call.

Reproducer, well-formed OHLC throughout (L <= C <= H), optInTimePeriod = 2:

bar0  H=10 L=5 C=7
bar1  H=L=C=7
bar2  H=L=C=7
bar3  H=9  L=7 C=8

gives bar3 = (+VI, -VI) = (0.0, -1.5) where the definition gives (1.0, 0.0). A negative -VI is unreachable — it is a ratio of sums of fabs() terms over a positive denominator.

At the default period the same shape (one normal bar, a 14-bar halt, then resumption) permanently poisons both lines: bars 15-17 come out (0.0, -1.5), (0.714286, -0.571429), (1.0, -0.333333) against a true (1.0, 0.0), (1.285714, 0.285714), (1.444444, 0.333333).

Why ULTOSC is not the precedent I claimed

ultosc.c:221 reseeds on trueRange == 0.0 && closeMinusTrueLow == 0.0both per-bar terms — and its comment states exactly why: "every slot it spans is 0.0, so its totals are known to be exactly zero." VORTEX has the shape but not the predicate, so the premise the reseed rests on does not hold for the numerators.

The fix

The load-bearing half of the original fix was the exact denominator gate, and that is correct as written — keep if( curTR > 0.0 ).

Reseed only sTR. Its window terms are provably all zero once nullRun >= optInTimePeriod, so dropping its rounding residue is sound. sVMP and sVMM must keep their ordinary add/subtract; there is no predicate here that proves their window terms are zero.

The comment block needs the same correction — as written it cites ULTOSC's reseed as validated precedent, which is the reason this reads as reviewed rather than as a defect, and it ships into all four backends.

Two smaller things

  1. vortex.c:178"Both external implementations also emit 0 here" is not right: pandas-ta-classic divides unguarded (NaN/inf), and Tulip has no vortex indicator to be the second.
  2. test_vortex.c:375 — leg 3b's comment points at bar 35, but at bar 35 the reseed fires and answers 0.0 regardless. Bar 34 is the only bar where the exact gate and a flat-count proxy disagree, and it is covered only by the loose finite sweep, not the exact pin. Worth pinning bar 34 explicitly, or the leg cannot fail on the defect it was added for.

None of the existing legs catch the numerator corruption: leg 3 uses H==L==C everywhere (where VMP/VMM genuinely are 0), leg 3b keeps re-firing the reseed, the TA_SREF corpus has no bar with TR == 0, and --xlang-hash's flat shape makes all four backends agree bitwise on the same wrong number.

@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Fixed at 07492f704. The numerator poisoning was exactly as you diagnosed — I had the shape of ULTOSC's reseed without its predicate. Now:

  • Reseed zeroes only sTR (nullRun proves every TR term in the window is zero; it proves nothing about VMP/VMM, which read the previous bar's extremes). The exact curTR > 0.0 division gate stays as the load-bearing half.
  • The comment block is rewritten around that distinction, including why ULTOSC's all-totals reseed is sound for ULTOSC and not here; the false "both external implementations" claim is gone.
  • Bar 34 is pinned exactly in the absorption leg (with the comment explaining why 35 could not fail on the defect), and a new halt-resume leg runs your reproducer shape — one spread bar, a full flat window, resumption — asserting non-negative lines on every bar and the resumed values against a naive fresh-sum reference at 1e-12. With the all-sums reseed re-introduced, that leg fails at the first resumed bar with the negative -VI; with the sTR-only reseed it is green.
  • Re-ran everything against this head: full --codegen rc 0, --xlang-hash zero non-baseline, cargo/clippy/regen-check clean.

…ange (TA-Lib#349)

+VI = SUM(|H - prevL|, n) / SUM(TR, n), -VI = SUM(|L - prevH|, n) /
SUM(TR, n) -- Botes & Siepman, TASC 28:1 (Jan 2010). Defaults 14, price
bundle HLC, two line outputs, no smoothing, no recursion, no unstable
period; bar 0 is consumed exactly as TA_TRANGE consumes it, so the first
output sits at index n, not n-1.

Zero-denominator handling, refined twice in review:
- The division is gated on the DENOMINATOR itself with an exact
  `curTR > 0.0`. A flat-bar count alone is only a proxy for sTR == 0 in
  exact arithmetic -- floating-point absorption can zero the running sum
  while the window still holds a live term, and the ungated division
  emits NaN/Inf on a function that does not declare them. An absolute
  TA_IS_ZERO band fails the QUOTE-UNIT/SCALE gate instead (VORTEX is
  homogeneous of degree 0).
- The flat-run purge zeroes ONLY sTR: nullRun counts exactly-zero TRUE
  RANGES, which proves every TR term in the window is zero -- it proves
  nothing about the vortex terms, which read the PREVIOUS bar's extremes
  and stay alive across a halt that follows a spread bar. Zeroing the
  numerator sums there poisons both lines permanently (an unreachable
  negative -VI). ULTOSC can reseed all its totals because its predicate
  covers both of its per-bar terms; VORTEX's covers only the
  denominator's.

The loop writes its outputs LAST: the trailing subtraction re-reads bars
trailingIdx-1 and trailingIdx, which sit exactly at and one past the
output slot, and the caller may alias any output onto any input. No gate
in the suite can catch an emit-first order, so test_vortex.c aliases
each of the 2 outputs onto each of the 3 inputs, bit-compared.

Tests (test_vortex.c): the bit-exact differential against TA_TRANGE +
TA_SUM + hand abs-diff numerators over a 6-period x 6-start grid
(4000-value floor, TA_SUM anchored at the clamped start, n=1 direct);
the issue's frozen triple-sourced goldens at rel 1e-12; all-flat guard
non-vacuity; the absorption reproducer with bar 34 pinned exactly (the
one bar where the exact gate and a flat-count proxy disagree); the
halt-resume reproducer (a spread bar, a full flat window, resumption)
asserting non-negative lines everywhere and the resumed values against a
naive fresh-sum reference; and startIdx==endIdx / clamping / n=1 edges.

The ta4j and trading-signals oracle arms named by the issue live in the
oracle infrastructure outside this repository; the goldens above carry
the formula proof here. CHANGELOG entry added.
@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Correction to my last comment, plus the fix, at c7cfdc007.

The sabotage claim I made ("with the all-sums reseed re-introduced, that leg fails at the first resumed bar") was wrong as first shipped — when I actually ran it, the leg stayed green. My halt was one bar too long: with resumption a bar later than yours, the all-sums purge re-fires on the extra flat bar and launders the negative sums before any live TR exposes them, so the corruption self-erases and the leg discriminates nothing.

Fixed by moving resumption to bar 15 exactly — the flat run first reaches n at bar 14, the purge fires while bar 1's live vortex terms are still in the window and about to be retired, and the very next bar divides by a live TR before any later purge can launder the sums. Now verified both ways: with the all-sums reseed re-introduced the leg fails at bar 15 with (0.444…, -1.111…) — the unreachable negative -VI — and with the shipped sTR-only reseed the full suite is green. The leg carries a comment explaining the phase, since one extra flat bar silently voids it.

@mario4tier
mario4tier merged commit c7cfdc0 into TA-Lib:dev Sep 5, 2026
7 checks passed
@kevinlincg
kevinlincg deleted the issue-349-vortex branch September 5, 2026 12:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants