You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
Output names follow the PLUS_DI/MINUS_DI and AROON (outAroonDown/outAroonUp) precedent. JSON-RPC keys become outPlusVI/outMinusVI.
flags: [stream]. Notstart_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.
No Achelis page: the indicator postdates Technical Analysis from A to Z.
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:
from-scratch numpy transcription of the StockCharts wording;
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 noCODEGEN_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).
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).
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.
Bar 0 has no TR/VMP/VMM (all need a prior bar), so it is consumed exactly as
TA_TRANGEalready consumes it (ta_codegen/input/trange/trange.c:33-43— "This function instead ignore the first price bar"). First valid output index is thereforen, notn-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
inPriceHLC(price_components: [high, low, close]) — the bundle ULTOSC/ADX/ATR already use, soTA_DEF_UI_Input_Price_HLCexists; no new UI const.outAroonDown/outAroonUp) precedent. JSON-RPC keys becomeoutPlusVI/outMinusVI.flags: [stream]. Notstart_dependent, no unstable period.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
ta_tulip_servevendor/tulipindicators/indicators/(onlycvi.c/nvi.c/pvi.c/vidya.cmatchv*); absent fromti_indicators[]. No golden vector.ta_pandas_servepandas_ta_classic/trend/vortex.py(0.6.52) read verbatim; matches StockCharts exactly. Addcompute_TA_VORTEX+ oneSPECS["TA_VORTEX"]line + PRIMARY membership + smoke_test + README row. Multi-output already supported (ERI=2, KC=3) — no protocol change.ta4j_serveVortexIndicator:VortexMovementIndicator(high, low)and(low, high)for the two numerators,TRIndicatorfor the denominator, each wrapped inRunningTotalIndicator(·, barCount);getPositiveValue(i)/getNegativeValue(i)are public, so both lines are readable without touchingcalculate()(which returns their difference).DEFAULT_BAR_COUNT = 14. RejectsbarCount <= 1.ta_trading_signalstrend/VI/VortexIndicator.jsrecomputes the whole window per bar:Σ|H[i]-L[i-1]|,Σ|L[i]-H[i-1]|,Σ trueRange.getRequiredInputs() = interval + 1so lookbackn;trueRange === 0yields{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 oneServer.Armimplementation plus oneARMS.put(...)entry (src/main/java/org/talib/oracles/ta4j/Server.java:148), thenmvn package.ta_trading_signals(trading-signals 8.3.0, TypeScript, MIT) is a capture script, not a server: add one line toARMSintrading_signals_serve/capture.mjs, thennode capture.mjs <NAME> '[[<params>]]'.One capture caveat for ta4j:
RunningTotalIndicatorkeeps apreviousIndexserial-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:
talib0.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_PRIVcorpus: 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):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
nin every case.Verification plan
test_vortex.c): the shipped fused loop vs a test-only reference built fromTA_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 owntest_vortex.crather than intotest_composite.c. It proves the fusion, not the formula.--codegenacross C/Rust/Java/.NET,--xlang-hash+server_verifyat zero tolerance (no transcendentals here — onlyfabs/max— so noCODEGEN_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, thestreamfill==batch differential,--fuzz-064(auto-skips a new function),doRangeTest(normal comparison — not path-dependent).non_zero_rangeepsilon), 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 == outputaliasing: required, see Notes. Model it onverify_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 < lookbackclamping, n=1 (window is a single bar; ratio still well-defined).Decisions
VortexIndicator.DEFAULT_BAR_COUNT = 14, trading-signals8.3.0
trend/VI/VortexIndicator.jsconstructor(interval = 14), pandas-ta-classiclength=14,StockCharts 14.
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 theInvestigate 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 hasoutIdx == 0andtrailingIdx == 1, so it readsinHigh[0]/inLow[0]/inClose[0]— which an emit-first order would already have clobbered if the caller aliasedoutPlusVI == 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:4876states "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-hashagrees 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 forinHigh/inLowattrailingIdxandtrailingIdx-1. Streaming is not optional: every shipped function declaresflags: [stream]. A body the analyzer will not fold is a body to reshape, not a reason to ship batch-only. Failure is loud —generatecallsexit(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.driftstays fixed at the canonical 1. Defaultsoffset=0, min_periods=lengthreproduce the canonical output exactly, so dropping them introduces no ambiguity.Wiring gotchas that are actually forgotten:
cargo run -- generate— a--func=VORTEXfilter skips the assembled JavaCore.java.scripts/build.py servers, not justbuild, or the new function's gate coverage is silently stale.test_vortex.cin BOTHCMakeLists.txt(_TA_BUILD_TOOLS) andsrc/tools/ta_regtest/Makefile.am— the dist nightly builds with autotools.scripts/build.py check-source-listsverifies they agree. Plusta_test_func.hand theDO_TESTentry inta_regtest.c.src/tools/ta_regtest/test_codegen.c:4555(currently162).