Skip to content

ta.py 2.1.0

Latest

Choose a tag to compare

@Vultwo Vultwo released this 10 Oct 13:10
dc481fe

Released 2026-10-10. Full details in CHANGELOG.md.

On PyPI as ta_py 2.1.0.

⚠️ Read this before upgrading

Seven functions return different numbers, and three of them return a different number of rows. Each was verified against the implementation it was ported from (ta.js, or the Pine original) and each fix has a test that fails against the old code — but nothing raises, so an existing pipeline will consume the new values silently. They were found by running identical data through all three ports and comparing: ta.py disagreed with ta.js and ta.go, and in every case ta.py was the one that was wrong.

function what changed
roc values: returned a fraction, now percent (100 * (last - reference) / reference). kst and cop are built on it and move with it.
kst signature: positional parameters were interleaved (r1, s1, r2, s2, …), now grouped (r1..r4, s1..s4, sig) as in ta.js. Defaults unchanged; positional callers are not.
ha row count: n + 1 → n. The loop re-processed bar 0, so the second row was a duplicate over the seed.
aroon_up, aroon_osc values: inverted — 0 when the window's high was its newest bar, 100 when it was the oldest. aroon_down was already correct.
psar values: the downtrend extreme tracked the high instead of the low, so the SAR sat above the market through every decline.
zigzag values: five bookkeeping deviations from ta.js — pivots were delayed a bar, legs were reset from ±infinity, and the flat-input branch mixed its indices.
hull values: the half-length and the final smoothing window were truncated instead of rounded, so the output differed from ta.js at every length that is 1 mod 4 (5, 9, 13, 17, …).
er, ar, kelly one-sided input: raised ZeroDivisionError; now returns the geometric mean of the side that exists, and a Kelly fraction of 1 / negative.

If you index results by bar, or compare them against stored values, re-run before trusting them.

Added

  • occ(data, basis_len=8, basis_type="SMMA", delay=0) — Open Close Cross, from JustUncleL's Pine (source), requested in #4. Moving averages of open and of close; the trend is whichever is on top and a flip is the signal. Rows are [open, close] and the output aligns to the input bars as [close basis, open basis, direction], NaN until both bases exist. delay is the Pine's delayOffset, which shifts both bases right.

    Eight of the Pine's twelve basisType options are supported: SMA, EMA, DEMA, TEMA, WMA, SMMA, HullMA, TMA. The other four are omitted rather than approximated — LSMA takes an offset parameter lsma does not have, VWMA needs volume in the data row, and ALMA and SSMA (Ehlers SuperSmoother) are not implemented in this library. TradingView's alert conditions, alternate-resolution mode and divergence plot are platform features, not computations, and have no counterpart in a library that returns series.

    Two caveats worth knowing. The Pine's variant definitions were checked against the existing primitives for DEMA, TEMA and Hull across even and odd lengths (~1e-11, float noise), so those are the same averages. And with an odd basis_len, Hull's half-length is rounded to even in Python where Pine rounds half away from zero, so that one combination may differ; every other type is unaffected.

Fixed

  • hull rounded where the reference rounds half up. Its half-length came from round(l / 2), and Python's round() is half-to-even: for lengths where l / 2 is a half-integer with an even integer part — 5, 9, 13, 17, … — it gave 2 where ta.js's Math.round gives 3, so the whole line differed. Measured against ta.js on one shared series, lengths 2-20: differing at 5, 9, 13 and 17 before, none after. Found while checking occ's HullMA basis, which inherits it.

  • alligator / gator no longer match TradingView by accident. Both were returning their rows newest-first and truncated to n - jl - js + 2 rows, so they could not be lined up with the input series at all, and each line was displaced by shift - 1 bars instead of shift. gator's upper histogram also had the wrong sign — it plotted jaw - teeth instead of |jaw - teeth|. Reported in #10.

  • kama now computes Kaufman's adaptive average correctly. Four bugs, all inherited from the ta.js original: the smoothing constant dropped its slow term (- 2/(l3+1) + 2/(l3+1) cancelled, leaving ER * fast); the volatility window summed l1-1 one-bar changes instead of l1, which let the efficiency ratio exceed 1 and divided by zero on flat input; the seed was the SMA of the last window rather than the first, so the opening value was computed from data at the end of the series; and the loop started one bar late, emitting n - l1 values instead of n - l1 + 1. Nothing raises before or after — every call simply returns different numbers.

    The recursion now matches tulind, the reference the benchmarks compare against: bit-identical on short, flat, trending and gapping series, max Δ 1.4e-14 on a 100k random walk. Carrying the volatility sum across bars instead of re-summing it per bar also makes it ~2.5× faster. Fixes the same defect as Bitvested/ta.js#22, mirrored in ta.go.

  • halftrend never flipped, and read its inputs from the wrong bars. Reported in #7. Three defects against the Pine it was ported from (everget's HalfTrend):

    • the flip tests read sma(...)[0] — the SMA at the start of the window, roughly atrlen bars old — instead of the SMA at the current bar. Both flip conditions therefore compared stale values and never fired: on 4901 bars of the reporter's 5-minute data the indicator produced zero trend changes and stayed on one line.
    • the price extremes behind maxlow / minhigh were taken over the ATR window (atrlen bars) instead of amplitude bars, and the down-flip compared close with < where the Pine uses >.
    • the window was data[i-atrlen:i], excluding the current bar, so every value was one bar stale.

    The rewrite follows the Pine line by line: one row per input bar, extremes over amplitude, the state machine evaluated at the current bar, and an ATR that uses Pine's own seeding (Wilder's RMA seeded with the mean of the first atrlen true ranges, which is not what ta_py.atr does). On the reporter's data it now flips 160 times. Pinned by tests/test_halftrend.py, which fails against the previous implementation.

Changed — BREAKING for zigzag consumers

  • zigzag now follows the ta.js algorithm, which the audit showed it did not. Five deviations, all silent: extending a running pivot continued past the bar's move test, delaying every pivot by a bar; the leg reset seeded min/max with ±infinity where the original seeds them with the new extreme; the flat-branch bounds compared data[i][0] but stored data[i][1]; and ZeroDivisionError fallbacks replaced the original's IEEE arithmetic. On a 300-bar series it disagreed with ta.js and ta.go in 186 of 298 values; it now matches both exactly (max Δ 0.0), on high/low pairs and flat input alike. zigzag__pairs pinned the old interpolation and is re-pinned.

Changed — BREAKING for psar consumers

  • psar tracked the wrong extreme in a downtrend. Where it should have taken the lowest low as the extreme point, it took the high, so the stop-and-reverse sat above the market through a decline and every subsequent value was wrong. On a 300-bar series it disagreed with ta.js and ta.go in 63 values; it now matches both exactly (max Δ 0.0). tests/test_indicators.py pins an 18-bar decline in which the fix moves 12 values.

Changed — BREAKING for er, ar and kelly consumers

  • A one-sided series is no longer an error. er divided by the count of the side that may be empty, so a series with no losses raised ZeroDivisionError, and ar and kelly raised with it. An empty side carries weight zero, so its geometric mean is never taken and contributes nothing: er([0.02, 0.01, 0.05, 0.03]) is now 0.0274 (the geometric mean of the wins), ar returns finite residuals, and kelly returns 1.0 for an all-winning series and -1.0276 for an all-losing one. Mixed series are unchanged to the last bit.

    The same input produced three different answers across the ports — NaN in ta.js (JS evaluates 1 ** Infinity as NaN), a finite value in ta.go (where math.Pow(1, +Inf) is 1), an exception here — and all three now return these numbers, with the empty side handled explicitly rather than left to each language's exponentiation edge case.

Changed — BREAKING for roc, kst, ha and aroon consumers

Found by a cross-language audit (identical series through 60 indicators in ta.js, ta.go and ta.py): ta.py disagreed with both siblings on these. Nothing raises — every call returns different numbers.

  • roc now returns percent, not a fraction. 100 * (last - reference) / reference, matching ta.js, ta.go and the usual convention; ta.roc([1, 2, 3, 4], 3) was [2, 1] and is now [200.0, 100.0]. A zero reference yields NaN instead of raising ZeroDivisionError. kst and cop are built on roc and move with it.
  • kst's positional parameters are reordered to (data, r1, r2, r3, r4, s1, s2, s3, s4, sig) — rates first, then smoothing — which is what ta.js accepts. The old signature interleaved them (r1, s1, r2, s2, …), so a positional call that used to mean r1=5, s1=5, r2=7… now means r1=5, r2=5, r3=7…. The defaults are unchanged in effect, and callers passing keywords are unaffected.
  • ha returns n rows for n inputs, not n + 1. Its loop re-processed bar 0, so the second row was a duplicate computation over the seed. len(out) == len(data) now holds, as it does in ta.js and ta.go.
  • aroon_up was inverted. It returned 100 - aroon_up, i.e. 0 when the window's highest high was its newest bar and 100 when it was the oldest — the opposite of the standard, of ta.js and of ta.go. aroon_down was already correct; aroon_osc follows aroon_up. The pinned aroon fixtures never caught it because they placed the extreme at a tie and at the window's middle, the two positions where an inverted Aroon returns the same number; tests/test_indicators.py now pins the discriminating cases. A window of one bar has no width, so aroon_up/aroon_down return NaN there instead of 0, matching the siblings.

Changed — BREAKING for halftrend consumers

  • halftrend returns one row per input bar instead of n - atrlen, so len(out) == len(data) and row i covers bar i. The ATR channel is NaN until the ATR window is full (bar atrlen - 1); the trend line itself is defined from bar 0. Callers that index-shifted the old output to line it up with their data — as in the issue report — no longer need to.

Changed — BREAKING for alligator / gator consumers

  • alligator(data, ...) / gator(data, ...) output is now aligned to the input bars. len(out) == len(data) and row i covers bar i. Each line is displaced right by its own shift, matching TradingView's plot(..., offset=shift), so row i holds the SMMA value computed at bar i - shift. A line is NaN until bar length - 1 + shift (defaults: jaw at 20, teeth at 12, lips at 7). gator is now derived from alligator's displaced lines, which also makes it the four-argument-free way to reach TradingView's Gator Oscillator: pass js=ts=ls=0.

    Nothing raises — every caller below gets different numbers, not an error:

    • Length changed from n - jl - js + 2 (with defaults, n - 19) to n. Anything keyed off len(out), sliced by it, or zipped against a same-length series behaves differently.
    • Row order reversed. out[0] used to be the newest value and out[-1] the oldest; now out[0] is bar 0 and out[-1] is the newest row, which itself holds the value computed 8 bars earlier.
    • Leading rows are NaN where they used to be numbers. sum, mean, min or any comparison over the whole array now propagates NaN, and out[0][0] is nan rather than the latest jaw.
    • gator's upper histogram changed sign from jaw - teeth (negative) to |jaw - teeth| (non-negative). The lower histogram is unchanged.

    The old output was unusable for its stated purpose — that is issue #10 — but it was published in 2.0.0, so code written against it will change behaviour silently. See MIGRATION.md for the before/after and for how to recover the old array shape if you depended on it.

data = [8, 7, 8, 9, 7, 8, 9, 6, 7, 8, 6, 8, 10, 8, 7, 9, 8, 7, 9, 6, 7, 9]

# 2.0.0 — 3 rows, newest first, not aligned to `data`
ta_py.alligator(data)
# → [[7.726445152480656, 7.813665029359981, 7.6378735707815935],
#    [7.78698224852071, 7.929902890697122, 8.047341963476992],
#    [7.769230769230769, 7.919889017939568, 7.809177454346241]]

# unreleased — 22 rows, one per bar of `data`
ta_py.alligator(data)
# → [[nan, nan, nan], ... 19 more ..., [7.769230769230769, 7.919889017939568, 7.809177454346241],
#    [7.78698224852071, 7.929902890697122, 8.047341963476992]]