Skip to content

feat(er): Kaufman Efficiency Ratio, KAMA's inner ratio standalone (#350) - #378

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

feat(er): Kaufman Efficiency Ratio, KAMA's inner ratio standalone (#350)#378
mario4tier merged 3 commits into
TA-Lib:devfrom
kevinlincg:issue-350-er

Conversation

@kevinlincg

Copy link
Copy Markdown
Collaborator

Implements #350: TA_ER, Kaufman's Efficiency Ratio — |c[t] − c[t−P]| / Σ|one-bar changes| over the same P bars, the exact ratio TA_KAMA computes internally, exposed standalone and kept bit-identical to it. Default 10 (the author's own; TA_KAMA's same window defaults to 30 — divergence documented in er.md), lookback P, flags: [stream] (T3 trailing ring, folded first try), no unstable period.

The issue's "verbatim kama.c" predates #253 — the current source wins

The snippet in the issue carries TA_IS_ZERO(sumROC1); current kama.c does not — #253 replaced it with the flat-run count, and the QUOTE-UNIT/SCALE gate is why (measured here: the old band fails it at 2^-120; ER is homogeneous of degree 0, and a fixed 1e-14 met a price-carrying sum). So the true verbatim lift is: nullRun >= P purges the running sum to an exact 0.0, and the signed sumROC1 <= periodROC clamp — both sides carrying the quote unit — answers the flat window's 0/0 with 1.0 through 0 <= 0. Decision 3's ruled value (flat ⇒ exactly 1.0) is unchanged; only the mechanism follows current kama.c.

The clamp's asymmetry is preserved and documented per decision 4: it compares against the signed numerator, fires only on advances, and on sustained declines the raw fabs ratio may exceed 1.0 by a few ULP. Not "fixed" — that would change TA_KAMA.

Tests (test_composite2.c, tag now SMI,COPPOCK,ER — same placement rationale as SMI: the reference re-applies hand-written guards, so it is not a pure composition)

  • Composite differential, bit-exact (memcmp): shipped TA_MOM(P) + |TA_MOM(1)|TA_SUM(P) with the two decisions re-applied, 4 periods × 6 startIdx (≥3000-value floor), TA_SUM anchored at the fused loop's clamped start.
  • KAMA reconstruction, bit-exact: rebuild TA_KAMA from TA_ER via sc = fma(er, constDiff, constMax)², prev = fma(c − prev, sc, prev), memcmp'd against TA_KAMA at unstable 0. The generated ta_KAMA.c lowers both steps through explicit fma() (Evaluate retiring TA_EMA_Private: a vestige, but it silently suppresses FMA on EMA #183), so the reconstruction must too — the plain-multiply form diverges in the last bit (measured, bar 23). A regression guard against the two bodies drifting, per the issue: not independent evidence.
  • Formula: the issue's frozen pandas goldens (bitwise-identical there to a faithful transcription) at rel 1e-12.
  • Edges: dead-flat ⇒ exactly 1.0; round-trip zigzag ⇒ exactly 0.0; monotone-up ⇒ exactly 1.0 (the clamp fires); monotone-down inside 1 ± 1e-12 with no equality assertion (the clamp never fires there — decision 4's asymmetry, asserted as a band); outReal == inReal aliasing (the trailingValue cache).

Verification

Full --codegen (four languages) rc 0 including QUOTE-UNIT/SCALE, stream_verify green, --xlang-hash zero non-baseline mismatches, full generator suite + clippy + regen-check + check-source-lists clean. The pandas/ta4j arms named by the issue live in the oracle infrastructure outside this repo (the ta4j arm is the KAMA-inversion construction the issue conditions-tested); the goldens above carry the formula proof here. CHANGELOG entry added.

…-Lib#350)

ER[t] = |c[t] - c[t-P]| / SUM |one-bar changes| over the same P bars --
Perry J. Kaufman, Smarter Trading (1995). Default 10 (the author's own;
TA_KAMA's same window defaults to 30 -- documented divergence), group
Momentum Indicators, single real in/out, lookback P, no unstable period.

The body is a verbatim lift of CURRENT kama.c, which is not the issue's
snippet: that predates TA-Lib#253. The absolute TA_IS_ZERO band it carried
fails the QUOTE-UNIT/SCALE gate (ER is homogeneous of degree 0; a fixed
1e-14 met a price-carrying sum). Current kama.c counts exactly-zero
one-bar changes instead -- nullRun >= P purges the running sum to 0.0,
after which the signed `sumROC1 <= periodROC` clamp (both sides carry
the quote unit) answers the flat window's 0/0 with 1.0 through 0 <= 0.
The clamp fires only on advances; on sustained declines the raw fabs
ratio may exceed 1.0 by a few ULP, documented as such.

Tests (test_composite2.c, tag now SMI,COPPOCK,ER):
- a bit-exact composite differential against shipped TA_MOM(P) +
  |TA_MOM(1)| -> TA_SUM(P) with the two decisions re-applied, over a
  4-period x 6-start grid (3000-value floor), TA_SUM anchored at the
  clamped start;
- a KAMA-reconstruction differential: rebuild TA_KAMA from TA_ER via
  sc = fma(er, constDiff, constMax)^2 and prev = fma(c-prev, sc, prev),
  memcmp'd -- the generated ta_KAMA.c lowers both steps through explicit
  fma() (TA-Lib#183), so the reconstruction must too or it diverges in the
  last bit (measured at bar 23). A regression guard against the two
  bodies drifting, not independent evidence;
- the issue's frozen pandas goldens at rel 1e-12;
- edges: dead-flat == exact 1.0, round-trip zigzag == exact 0.0,
  monotone-up == exact 1.0 (the clamp), monotone-down inside 1 +- 1e-12
  (the clamp's asymmetry: never exact equality), and outReal == inReal
  aliasing (the trailingValue cache).

The pandas/ta4j 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.
@mario4tier

Copy link
Copy Markdown
Member

Reviewed. One blocker, two vacuous legs, and some doc items.

Blocker — the asymmetric clamp cannot serve as the zero guard

er.c:136 (and :100) is if( sumROC1 <= periodROC ) ... else fabs( periodROC / sumROC1 ).

sumROC1 is a running add/subtract sum, so it can reach exactly 0.0 on a window that is not flat — an addend absorbed on the way in, subtracted later at full precision. When that coincides with a down move (periodROC < 0), the test 0.0 <= negative is false, so the else arm divides by zero and ER returns ±Inf.

Keeping the clamp asymmetric is right — it is what buys KAMA parity, and er.c:42-46 is correct to warn against "fixing" it with fabs. The issue is that it is doing double duty. It pins the ratio where FP noise would overshoot 1; it is not a denominator guard, and cannot be one for negative periodROC. The nullRun purge does not cover this either: it only fires on a fully flat window.

An exact sumROC1 > 0.0 test on the division, with the clamp left exactly as it is, closes it without touching KAMA parity.

Two legs that cannot fail

  1. test_composite2.c:1327 — the dead-flat leg is a no-op. Its series is buf[i] = 100.0 for all 64 bars, so the priming sum is already exactly 0.0 and the nullRun >= optInTimePeriod purge never changes anything. Delete the purge — the guard er.c itself calls "load-bearing" and er.md documents — and every assertion this PR adds still passes bit-identically. The purge is your deliberate deviation from the card's TA_IS_ZERO, so it is the one thing that most needs a leg that can see it. It needs a window that goes flat after live bars, not one flat from bar 0.
  2. test_composite2.c:1375 — the monotone-down leg discriminates nothing. On buf[i] = 200.0 - i*0.7 every one of the 54 outputs is exactly 1.0 under both clamps, so the 1 ± 1e-12 band is satisfied either way. It was added to cover the signed-vs-absolute asymmetry and the edit er.c explicitly forbids survives the whole suite.

Documentation

  • er.md:29 restates two YAML numbers in prose (ER's default 10 and KAMA's 30). docs/ta_codegen_input_doc.md:36-45 is explicit that defaults live only in the YAML and are injected at render time; the generator then appends its own clause, so the shipped docs say "default 10" twice in one line.
  • er.md has no ## Implementation section — all 189 other input .md files carry it, and docs_site.rs's own doc comment calls it "present on every page". ## Aliases, ## See Also and ## References are also absent despite the Summary citing Kaufman, Smarter Trading (1995).
  • er.md:5 — the Summary's first sentence contains "Perry J. Kaufman". docs_site.rs's truncate_description treats . + space + uppercase as a sentence end, so the generated page description is cut mid-name.

…ble to fail

Review of TA-Lib#378.

The asymmetric clamp cannot double as the zero guard. It compares against
the SIGNED numerator, so it is false for every down move, and a
subtract-then-add sum can reach exactly 0.0 -- or below it -- on a window
that is not flat, when a term absorbed on the way in is subtracted later
at full precision. Those bars divided by zero and returned +Inf. The
division now runs only where sumROC1 is exactly positive; the clamp is
untouched, so KAMA parity is untouched. The nullRun purge does not cover
this case: the window is not flat.

Three legs, each with the control that reddens it:

  - zero denominator, new. A 1e16 step then a decline, at P=5. Drop the
    guard and bar 6 is +Inf.
  - dead-flat, new second leg. The old one is flat from bar 0, where the
    priming sum is already an exact 0.0 and the purge can change nothing;
    this one goes flat after live bars, so the sum carries residue. Delete
    the purge and every asserted bar reports 0.0 -- a flat market read as
    maximally inefficient -- while the old leg stays green.
  - monotone-down. On a uniform decrement all 54 outputs are exactly 1.0
    under either clamp, so the 1 +- 1e-12 band was satisfied by the fabs
    edit er.c forbids. Two alternating decrements put 24 outputs strictly
    above 1.0, and the leg now counts them: with fabs the count is 0.

Docs: er.md restated the two YAML defaults in prose, was missing
Implementation, Aliases, See Also and References, and opened the Summary
with an initial that truncate_description cuts the page description at.
Every conflict was a generated file, rebuilt by `generate` rather than
merged by hand.

Both sides had allocated internal error id 424 -- VORTEX's is the one
upstream ships, so ER'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

Copy link
Copy Markdown
Member

All four findings verified fixed — thanks, that was fast.

  • er.c:114,150sumROC1 <= 0.0 || sumROC1 <= periodROC. The exact denominator test first, the asymmetric clamp untouched, so KAMA parity is preserved and the flat answer stays 1.0.
  • The dead-flat leg is replaced with a flat-after-live-bars series, which is the only shape the nullRun purge can be observed in, and your comment states why the residue is visible (positive, so residue <= 0 cannot mask it).
  • ## Implementation restored; the restated YAML numbers are gone.

Stop rebasing for now — the id will collide no matter what you do. #379 ERI and this branch both rebased onto c7cfdc007, where next: 425, so you both took 425. Whichever lands second has to move to 426, and rebasing again just re-takes 425 from the same base.

ERI is gating now and will land first. I will do this branch's regenerate myself when I land it, exactly as with COPPOCK — drop the unshipped ER.ringcap.trailingIdx entry and let generate reallocate. Nothing needed from you.

Worth knowing for future PRs, since this has now bitten four times: generate only assigns numbers to new keys. A duplicate that arrives through a merge survives regeneration untouched, and regen-check cannot see it because the file is self-consistent. The only thing that catches it is the generator's own one_id_names_one_guard test.

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