Skip to content

(VORTEX) Vortex Indicator #349

Description

@mario4tier

What it is

Two-line trend indicator: positive/negative "vortex movement" normalized by true range. Etienne Botes & Douglas Siepman, Technical Analysis of Stocks & Commodities 28:1 (Jan 2010), pp. 20-30.

Formula

No smoothing, no recursion, nothing to seed — three rolling sums over a ratio.

per bar i >= 1:
  TR[i]  = max( H[i]-L[i], |C[i-1]-H[i]|, |C[i-1]-L[i]| )   /* == TA_TRANGE */
  VMP[i] = |H[i] - L[i-1]|
  VMM[i] = |L[i] - H[i-1]|

output at bar t >= n   (n = optInTimePeriod):
  sTR = SUM(TR[t-n+1..t]); sP = SUM(VMP[..]); sM = SUM(VMM[..])
  outPlusVI  = TA_IS_ZERO(sTR) ? 0.0 : sP / sTR
  outMinusVI = TA_IS_ZERO(sTR) ? 0.0 : sM / sTR

Bar 0 has no TR/VMP/VMM (all need a prior bar), so it is consumed exactly as TA_TRANGE already consumes it (ta_codegen/input/trange/trange.c:33-43 — "This function instead ignore the first price bar"). First valid output index is therefore n, not n-1.

No definitional ambiguity. StockCharts, Wikipedia and TradingView state the identical formula; there is no smoothing variant, no seeding variant, no scale constant. The only cross-source disagreement is the suggested default period: 14 (StockCharts, pandas-ta-classic, ta4j, trading-signals, "commonly 14-30" per TradingView) vs 21 (Wikipedia's worked example). Default 14, range [1,100000] — see Decisions.

Proposed API

int TA_VORTEX_Lookback( int optInTimePeriod );   /* = trange_lookback() + optInTimePeriod - 1
                                                    = 1 + (n-1) = n */

TA_RetCode TA_VORTEX( int    startIdx,
                      int    endIdx,
                      const double inHigh[],
                      const double inLow[],
                      const double inClose[],
                      int    optInTimePeriod,   /* [1..100000], default 14, suggested 1..200 step 1 */
                      int   *outBegIdx,
                      int   *outNBElement,
                      double outPlusVI[],       /* real, flags: [line] */
                      double outMinusVI[] );    /* real, flags: [line] */
  • Price bundle inPriceHLC (price_components: [high, low, close]) — the bundle ULTOSC/ADX/ATR already use, so TA_DEF_UI_Input_Price_HLC exists; no new UI const.
  • ta_abstract group: Momentum Indicators (with ADX/DX/PLUS_DI/MINUS_DI/ULTOSC).
  • Output names follow the PLUS_DI/MINUS_DI and AROON (outAroonDown/outAroonUp) precedent. JSON-RPC keys become outPlusVI/outMinusVI.
  • flags: [stream]. Not start_dependent, no unstable period.
  • Implementation mirrors ta_codegen/input/ultosc/ultosc.c: priming loop over [startIdx-n+1, startIdx), then a steady loop. See Notes for the mandatory loop ordering.

References

Oracles

Oracle Status Notes
ta_tulip_serve IMPOSSIBLE Tulip 0.9.2 does not implement Vortex. Verified by listing vendor/tulipindicators/indicators/ (only cvi.c/nvi.c/pvi.c/vidya.c match v*); absent from ti_indicators[]. No golden vector.
ta_pandas_serve must be ADDED, ~12 LOC / ~30 min pandas_ta_classic/trend/vortex.py (0.6.52) read verbatim; matches StockCharts exactly. Add compute_TA_VORTEX + one SPECS["TA_VORTEX"] line + PRIMARY membership + smoke_test + README row. Multi-output already supported (ERI=2, KC=3) — no protocol change.
ta4j_serve arm to add ta4j 0.22.6 VortexIndicator: VortexMovementIndicator(high, low) and (low, high) for the two numerators, TRIndicator for the denominator, each wrapped in RunningTotalIndicator(·, barCount); getPositiveValue(i) / getNegativeValue(i) are public, so both lines are readable without touching calculate() (which returns their difference). DEFAULT_BAR_COUNT = 14. Rejects barCount <= 1.
ta_trading_signals arm to add, one line trading-signals 8.3.0 trend/VI/VortexIndicator.js recomputes the whole window per bar: Σ|H[i]-L[i-1]|, Σ|L[i]-H[i-1]|, Σ trueRange. getRequiredInputs() = interval + 1 so lookback n; trueRange === 0 yields {plus: 0, minus: 0}.

VORTEX has no seeding, no recursion and no unstable period, so both new arms are full-range value oracles — the strongest arm class available, and they replace what the missing Tulip arm would have provided. ta4j_serve (ta4j 0.22.6, Java, MIT) takes one Server.Arm implementation plus one ARMS.put(...) entry (src/main/java/org/talib/oracles/ta4j/Server.java:148), then mvn package. ta_trading_signals (trading-signals 8.3.0, TypeScript, MIT) is a capture script, not a server: add one line to ARMS in trading_signals_serve/capture.mjs, then node capture.mjs <NAME> '[[<params>]]'.

One capture caveat for ta4j: RunningTotalIndicator keeps a previousIndex serial-access cache and takes an incremental fast path when bars are requested in order, a fresh window sum otherwise. Drive the capture strictly in ascending index order so the arm is deterministic.

Two further independent implementations, both already run this session and both bit-exact to pandas:

  1. from-scratch numpy transcription of the StockCharts wording;
  2. a talib 0.6.8 primitives composition (TRANGE -> SUM(n) denominator; |H[1:]-L[:-1]|, |L[1:]-H[:-1]| -> SUM(n) numerators).

MEASURED on the 252-bar TA_SREF_{high,low,close}_daily_ref_0_PRIV corpus: the proposed running-sum formulation == pandas-ta 0.6.52 == the TRANGE+SUM composition with max relative diff exactly 0.0 at n = 2, 14, 21, 30. Goldens (n=14, outBegIdx=14, nb=238):

idx VI+ VI-
14 0.91516119373190941 0.90527996806068456
20 0.90702681542376196 0.96036406341749847
50 0.99915659263424228 0.81993252741073930
125 1.19342208300704770 0.63997650743931100
200 0.72637931034482750 1.22413793103448270
251 0.93942403177755707 1.02333664349553130

Pin goldens at relative 1e-12, not bitwise. pandas' rolling().sum() is Kahan-compensated; MEASURED it is bit-identical to a naive add/subtract running sum at n <= 30 on this corpus but drifts 1.5e-15 relative at n=100. Over-tightening to bitwise buys a nightly red on a different period or corpus.

Lookback MEASURED against pandas for n = 1, 2, 14, 21, 30, 100: first non-NaN index is exactly n in every case.

Verification plan

  • Differential leg (test_vortex.c): the shipped fused loop vs a test-only reference built from TA_TRANGE + TA_SUM + a 3-line abs-diff for the two numerators. This is the same construction already proven bit-exact against pandas, so it can be asserted bit-exact. Note this is the composite-category pattern (ta_test_func/test_composite.c, Add PVO (Percentage Volume Oscillator) + a composite-function test category #119) but VORTEX is not a pure composition of shipped functions — the numerators need the abs-diff helper — so it goes in its own test_vortex.c rather than into test_composite.c. It proves the fusion, not the formula.
  • Formula proof: the frozen goldens above, rel tol 1e-12, triple-sourced (pandas-ta / numpy textbook / talib-primitives).
  • Free from the harness: --codegen across C/Rust/Java/.NET, --xlang-hash + server_verify at zero tolerance (no transcendentals here — only fabs/max — so no CODEGEN_TRANSCENDENTAL[] entry and no Java 1e-9 carve-out), the Audit parameter-boundary OOB cases; extend ta_regtest to sweep min/default/default±1/max for every parameter #94 param/boundary sweep, ASan/UBSan, the stream fill==batch differential, --fuzz-064 (auto-skips a new function), doRangeTest (normal comparison — not path-dependent).
  • Hand-written edge cases:
    • All-flat input (H==L==C for the whole window ⇒ sTR==0). Non-vacuous: with no guard the result is NaN, so asserting exactly 0.0 proves the guard fired — same trick as CMOU. pandas also returns 0.0 here (via its non_zero_range epsilon), so the oracle corroborates rather than contradicts the guard. Satisfies IMI: successful call emits NaN on an all-flat window (0/0) — guard the divide, return 50.0 #112 (a successful call never emits NaN).
    • input == output aliasing: required, see Notes. Model it on verify_accbands_inplace_aliasing() (src/tools/ta_regtest/test_codegen.c:4870-4885) — each of the 2 outputs aliased onto each of the 3 inputs, bit-for-bit vs the separate-buffer result.
    • startIdx == endIdx, startIdx < lookback clamping, n=1 (window is a single bar; ratio still well-defined).

Decisions

  1. Default period 14. ta4j 0.22.6 VortexIndicator.DEFAULT_BAR_COUNT = 14, trading-signals
    8.3.0 trend/VI/VortexIndicator.js constructor(interval = 14), pandas-ta-classic length=14,
    StockCharts 14.
  2. Zero-denominator guard: TA_IS_ZERO(sTR), matching ULTOSC's identical pattern
    (ta_codegen/input/ultosc/ultosc.c:179-181). It is an absolute 1e-14 threshold and inherits the
    Investigate MFI / STOCHRSI algo issue when dealing with epsilon error. #107 scale wart — on sub-1e-14-scale data it zeroes a legitimate ratio. Both external
    implementations return 0 on a zero denominator (ta4j returns NaN; trading-signals returns
    {plus: 0, minus: 0}).

Notes

Loop ordering is load-bearing — write the outputs LAST. The obvious formulation (add today's terms, emit, then subtract the trailing terms) is in-place-unsafe and contradicts both repo precedents. The trailing subtraction re-reads inHigh[trailingIdx-1], inLow[trailingIdx-1], inClose[trailingIdx-1]. With lookback == n and startIdx clamped to n, the first iteration has outIdx == 0 and trailingIdx == 1, so it reads inHigh[0]/inLow[0]/inClose[0] — which an emit-first order would already have clobbered if the caller aliased outPlusVI == inHigh, an aliasing TA-Lib explicitly permits. Follow:

  • ta_codegen/input/ultosc/ultosc.c:229-235 — "Last operation is to write the output. Must be done after the trailing index have all been taken care of because the caller is allowed to have the input array to be also the output array."
  • ta_codegen/input/accbands/accbands.c:131-155 — multi-output form: snapshot the window sums into temps ("Record the current window sums"), subtract the trailing bar, then write all bands.

The safety margin under the correct order is exactly one slot, and no gate in the suite catches getting it wrong: test_codegen.c:4876 states "No generic gate exercises input==output, so verify it directly", and #108 covers only output-vs-output distinctness. All four backends would inherit the same wrong order from the single input .c, so --xlang-hash agrees bitwise on the wrong answer. Hence the mandatory hand-written aliasing test above.

Streaming. Expected automatic with flags: [stream] — the loop tier (trailing ring). ULTOSC's generated stream already reads the same series at two different lags inside one trailing block (src/ta_func/ta_ULTOSC.c:1288-1290, ring_trailingIdx1_inClose[(pos+cap-ringLag-1)%cap]), which is exactly what VORTEX needs for inHigh/inLow at trailingIdx and trailingIdx-1. Streaming is not optional: every shipped function declares flags: [stream]. A body the analyzer will not fold is a body to reshape, not a reason to ship batch-only. Failure is loud — generate calls exit(1) on a declared-but-unanalyzable body.

Not exposed (pandas-only knobs with no TA-Lib contract): offset (result displacement), min_periods (partial warm-up windows), fillna/fill_method. drift stays fixed at the canonical 1. Defaults offset=0, min_periods=length reproduce the canonical output exactly, so dropping them introduces no ambiguity.

Wiring gotchas that are actually forgotten:

  • Run a FULL cargo run -- generate — a --func=VORTEX filter skips the assembled Java Core.java.
  • Run scripts/build.py servers, not just build, or the new function's gate coverage is silently stale.
  • Register test_vortex.c in BOTH CMakeLists.txt (_TA_BUILD_TOOLS) and src/tools/ta_regtest/Makefile.am — the dist nightly builds with autotools. scripts/build.py check-source-lists verifies they agree. Plus ta_test_func.h and the DO_TEST entry in ta_regtest.c.
  • Add the CHANGELOG entry (forgotten for CMOU).
  • Bump the hardcoded function count at src/tools/ta_regtest/test_codegen.c:4555 (currently 162).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions