Skip to content

feat(eri): Elder Ray Index, Bull and Bear Power against one EMA (#361) - #379

Merged
mario4tier merged 3 commits into
TA-Lib:devfrom
kevinlincg:issue-361-eri
Sep 5, 2026
Merged

feat(eri): Elder Ray Index, Bull and Bear Power against one EMA (#361)#379
mario4tier merged 3 commits into
TA-Lib:devfrom
kevinlincg:issue-361-eri

Conversation

@kevinlincg

Copy link
Copy Markdown
Collaborator

Implements #361: TA_ERI — Bull Power = High − EMA(Close, n), Bear Power = Low − EMA(Close, n), both lines against one shared EMA (Elder, Trading for a Living, 1993). Default 13, two line outputs, lookback = ema_lookback(n) with the EMA unstable period consumed in the warm-up; UNSTABLE_MAP gains {"ERI", TA_FUNC_UNST_EMA} per the issue's item 3. One fused loop per the issue's Notes — a composed ema() + combine form cannot stream (the ACCBANDS rationale), and no compatibility branch (EFI/SMI precedent). No division anywhere: no NaN path (#112 by construction).

Two generator inventories, each caught by its own gate

ERI is the first function since those gates landed to be simultaneously fma-fusing (the inlined EMA recurrence lowers through explicit fma() via the #183 pass) and transitively unstable (through the hard-coded inner EMA). Both static inventories gained their row after their gate flagged the drift — FUSING_INVENTORY in backends/fma.rs and INHERITED in stability_suite.rs — which is exactly the drift those gates exist to catch (the efi incident in FUSING_INVENTORY's own docstring).

Tests (test_eri.c, registered in CMakeLists + Makefile.am + DO_TEST; note --function=ERI substring-matches HT_DCPERIOD, so the tag is exact)

  • Differential, bit-exact and non-tautological: shipped TA_EMA + two subtractions, memcmp'd over 5 periods × 6 startIdx × 4 EMA-unstable-periods (≥10000-value floor), with the bull ≥ bear invariant free on every cell. The fused loop reproduces ema.c's DEFAULT arm op for op — seed-sum order and warm-up both covered by the unstable axis.
  • Golden pins at the defaults, ABSOLUTE tolerance 1e-12 per the issue's decision 1: ERI is a cancelling difference and relative error is unbounded at the zero crossings that carry the signal (the issue measured 1.9e-10 rel at an out-value of −1.5e-4). Rows pinned away from zero crossings (|value| ≥ 0.5). Provenance: from-scratch transcription of ema.c's DEFAULT arm on the committed TA_SREF corpus — the NVI/PVI pattern; the issue measured pandas-ta-classic 0.6.52 against the same transcription at max abs 7.1e-14, so the rows hold against either.
  • Edges: all-flat ⇒ both lines exactly 0.0; high == low ⇒ the two lines bitwise identical; n=1bull == high − close bit for bit (the EMA identity survives the fma lowering with no special case — verified, not assumed).
  • In-place aliasing, each of the 2 outputs onto each of the 3 inputs — the widest aliasing surface of any recent addition; the bar's extremes are loaded into temps before either output is written.

Verification

Full --codegen (all four languages) rc 0, stream_verify green (tier T2 scalar recurrence), --xlang-hash zero non-baseline mismatches, full generator suite + clippy + regen-check + check-source-lists clean. The pandas oracle arm already exists in the oracle infrastructure outside this repo; the trading-signals arm is left to it. CHANGELOG entry added.

…ib#361)

Bull Power = High - EMA(Close, n), Bear Power = Low - EMA(Close, n),
both lines sharing ONE EMA -- Alexander Elder, Trading for a Living
(1993). Default 13, HLC bundle, two line outputs, lookback ==
ema_lookback(n) with the EMA unstable period consumed in the warm-up
(UNSTABLE_MAP gains {"ERI", TA_FUNC_UNST_EMA}). One fused loop, not
ema() + a combine map: a composed form cannot stream (raw bar inputs are
outside check_map_step's provenance -- the ACCBANDS rationale). No
compatibility branch (EFI/SMI precedent). No division: no NaN path.

Two generator inventories gain their ERI rows, each caught by its own
gate: FUSING_INVENTORY (the EMA recurrence lowers through explicit fma()
via the TA-Lib#183 pass) and stability_suite's INHERITED (unstable through the
hard-coded inner EMA).

Tests (test_eri.c):
- a bit-exact differential against shipped TA_EMA plus two subtractions,
  over a 5-period x 6-start x 4-unstable-period grid (10000-value floor),
  with the bull >= bear invariant free on every cell;
- golden pins at the defaults, ABSOLUTE tolerance 1e-12 with rows pinned
  away from zero crossings: ERI is a cancelling difference (high - EMA)
  and relative error is amplified by |EMA|/|out| without bound at the
  zero crossings that carry the signal (the issue measured 1.9e-10 rel
  at an out-value of -1.5e-4). Provenance: a from-scratch transcription
  of ema.c's DEFAULT arm on the committed TA_SREF corpus (the NVI/PVI
  pattern); the issue measured pandas-ta-classic 0.6.52 against the same
  transcription at max abs 7.1e-14 on this corpus;
- edges: all-flat (both lines exactly 0.0), high == low (the two lines
  bitwise identical), n=1 (EMA is the identity, bull == high - close bit
  for bit -- holds through the fma lowering with no special case);
- in-place aliasing, each of the 2 outputs onto each of the 3 inputs --
  the widest aliasing surface of any recent addition; the extremes are
  loaded into temps before either output is written.

The pandas oracle arm named by the issue already exists in the oracle
infrastructure outside this repository; the trading-signals arm is left
to it. CHANGELOG entry added.
@mario4tier

Copy link
Copy Markdown
Member

Reviewed. One blocker, one vacuous gate, and two doc items.

Blocker — the inlined EMA drops ema.c's period-1 identity arm

ema.c:61 carries an explicit if( optInTimePeriod == 1 ) short-circuit that returns a bit-exact copy of the input. eri.c's transcription has none, and eri.yaml declares range: [1, 100000], so period 1 is legal and reachable.

At n == 1, k is exactly 1.0 and the recursion degenerates to fma(x - prev, 1.0, prev) = fl(fl(x - prev) + prev), which equals x only while consecutive closes stay within a factor of two (Sterbenz). So TA_ERI(…, 1) is not high - TA_EMA(close, 1).

That breaks three things at once: the bit-exact contract issue #361 decision 2 ruled on, the assertion in your own comment at eri.c:44-47 ("the differential against shipped TA_EMA holds bitwise"), and the premise of the tests below.

The n=1 legs cannot catch ittest_eri.c:246 memcmps outBull against high - close, and n=1 is in eriPeriodGrid at :89 — because the TA_SREF corpus is Sterbenz-benign, so the recursion happens to return x on every bar of it. A series with a consecutive close ratio outside [0.5, 2] would turn both red.

The aliasing sweep detects nothing it exists for

test_eri.c:285 runs section 4 at optInTimePeriod = 13 only. There outBegIdx = 12, so every store sits 12 slots behind the load and none of the six aliasing combinations can bite. Running it at the minimum period is what would exercise it.

Documentation

  • eri.md:5 — the Summary hard-codes the default as though the period were fixed: "distance … from a 13-period EMA of the close". optInTimePeriod is a parameter with range 1..100000, so the definition is false for every other value. This sentence is the only definition carried into rustdoc, javadoc and the C# XML docs, so it ships to three backends.
  • eri.md:9 — "TradingView additionally plots the sum of the two as a third line" is not right. TradingView's built-in Bull Bear Power — which its own support page calls "otherwise known as the Elder-Ray Index" — plots only the sum, not three lines.

…n see it

Review of TA-Lib#379.

At n == 1 the EMA recursion is fl(fl(x-prev)+prev), which returns x only
while consecutive closes stay within a factor of two, so `TA_ERI(.., 1)`
was not `High - TA_EMA(Close, 1)`. ema.c carries an explicit copy arm for
exactly this; the transcription had dropped it.

The n=1 leg could not catch it: TA_SREF keeps consecutive closes inside
the Sterbenz interval, where the recursion happens to return x on every
bar. A second n=1 leg runs off that corpus -- two closes alternating at a
ratio near 8.9 -- where 32 of the 63 steps land off the close. Reverting
the arm turns it red at bar 1 (1.5000000000000284 vs 1.5) while the
TA_SREF leg stays green.

The aliasing sweep ran at period 13 only, where the lookback holds every
store 12 slots behind its load and none of the six combinations can bite.
It now also runs at the minimum period, where store and load index the
same bar.

Docs: the Summary defined ERI against "a 13-period EMA" though the period
is a parameter, and the sentence is what ships to rustdoc, javadoc and the
C# XML docs. TradingView's Bull Bear Power plots only the sum, not three
lines.
Both sides add a test file and a DO_TEST entry at the same spot in the
four hand-maintained lists; kept both. Every generated file was rebuilt
by `generate` rather than merged by hand.

Both sides had also allocated internal error id 424 -- VORTEX's is the
one upstream ships, so ERI's entry was dropped and `generate` reassigned
it to 425. The id suite catches the collision; a merge that only took
both YAML lines leaves it.
@mario4tier
mario4tier merged commit aebff42 into TA-Lib:dev Sep 5, 2026
7 checks passed
@mario4tier mario4tier mentioned this pull request Sep 5, 2026
@kevinlincg
kevinlincg deleted the issue-361-eri 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