Skip to content

feat(rvir): add TA_RVIR, the 1995 refined Relative Volatility Index (drafts#68) - #413

Open
kevinlincg wants to merge 2 commits into
TA-Lib:devfrom
kevinlincg:feat/ta-rvir
Open

feat(rvir): add TA_RVIR, the 1995 refined Relative Volatility Index (drafts#68)#413
kevinlincg wants to merge 2 commits into
TA-Lib:devfrom
kevinlincg:feat/ta-rvir

Conversation

@kevinlincg

Copy link
Copy Markdown
Collaborator

Implements TA_RVIR, Dorsey's 1995 refinement of his own RVI, from the proposal card in ta-lib-proposal-drafts#68. I claimed the card there before starting.

RVIR[i] = 0.5 * ( RVI(high)[i] + RVI(low)[i] ), both legs at the same parameters.

Two shape findings that changed the implementation

The fused body the card specifies cannot carry flags: [stream]. The card asks for one pass driving two copies of RVI's state. That body is expressible and I wrote it first, but it carries two window-start cursors and the streaming analyzer accepts exactly one:

YAML declares `streaming: true` but the function is not streamable at stage 1:
extrema automaton: expected exactly one window-start variable,
found ["trailingIdxH", "trailingIdxL"]

Streaming is an API-surface contract and fusion is an implementation preference, so I kept the former: composed, like KC, where each leg streams as its own sub-handle. The cost is one scratch buffer sized endIdx-startIdx+1.

The IR cannot express a leg-indexed inner loop. Expr::ArrayAccess(String, Box<Expr>) takes a name as its base, not an expression, and VarType has no pointer-array member, so legs[j][i] — the natural spelling of "do this twice" — has no representation. Worth knowing before anyone else reaches for it.

What the tests can and cannot falsify

Discrimination was measured, not assumed: each mutation was applied to the generator input and regenerated.

mutation differential composite degenerate tie edges (ASan)
averaging dropped, high leg returned alone RED RED green RED green
the two TA_RVI calls swapped green green green green green
lookback raised by one green green RED green green
every scratch allocation one element short green green green green RED

Three things that follow, all of them in the file's header comment:

  • The swap is green because the order genuinely does not matter. TA_RVI's write index trails its read index by the lookback and never overtakes it, so neither call order can corrupt an aliased input. I had written a comment claiming the order was what made aliasing safe; the mutation disproved it and the comment is now the corrected version. The aliasing leg therefore earns its keep against the scratch buffer's extent, not against the order.
  • The composite leg is structurally true against this body, which calls TA_RVI twice, so it cannot fail for an arithmetic reason. It is there for the fused rewrite, which would have to reproduce the same equality. The arithmetic is carried by the differential leg, which rebuilds both legs from TA_STDDEV and TA_RMA — so a defect shared by TA_RVI and TA_RVIR cannot hide in it.
  • Degenerate and differential are the pair that covers the warm-up. The differential leg anchors its reference at whatever outBegIdx the function reports, so it follows a lookback drift instead of catching it; the degenerate leg (inHigh == inLow, which must reduce to TA_RVI exactly) compares outBegIdx against an established function and is the only leg that sees it.

The scratch-extent case needs AddressSanitizer to be visible — a plain run of the one-element case is green either way. Control run: clean 0 errors → mutated heap-buffer-overflow, abort → restored 0 errors, generated file byte-identical.

Verified locally

  • python3 scripts/build.py generate — zero drift (generate twice, git diff unchanged)
  • ta_regtest — full run green; --function=RVIR exercises all seven legs with literal coverage counts (39757 / 4609 / 13707 / 243 / 106592 / 9218)
  • ta_codegen/generator — full suite green. Two lists needed RVIR and got it with its reason: stability_suite's inherited-instability set (RVIR inherits TA_FUNC_UNST_RVI through both legs) and streaming_suite's composed-sub-call set (safe for KDJ's reason — the low leg is handed outBegIdx/outNBElement themselves and they are returned unmodified, so the final count is that callee's count)
  • ta_codegen/output/rust — 703 tests green
  • C library builds clean

Not verified locally

The Java and C# outputs were never compiled. This machine has no JDK that accepts release 17 and its .NET SDK (7.0.200) cannot target net10.0. What I did instead is a syntax-level check against KC, the composed function this one is shaped after: brace and paren balance after stripping comments and string literals, identical to KC's. That is not a compiler. If CI is red on either, it will be in the generated fragment and I will fix it there.

I do not think this blocks review — the same generator paths produce KC's Java and C# today and they are green in CI — but the claim "this compiles" is one I have not earned, so I am not making it.

Naming

The card left the name open between RVIR and alternatives. I took RVIR, the name the card recommends. rvir.md and rvi.md now carry reciprocal notes about the collision, because some vendors reserve the bare name RVI for this revision and call the 1993 form RVIorig; this library ships the 1993 form as RVI.

rvir.md also records the measured distance to five variants published under this name, so the next person to receive a "your RVIR is wrong" report can check which one they are holding: a plain exponential smoother instead of Wilder's, up to 12.9 index points; averaging the prices and taking one index of the result, 13.6; adding the close as a third leg, 6.0; a 9-period deviation, 3.2; routing ties to the down bucket, 4.6. Against RVI of the closes at shared defaults, 18.1 — this is not a re-parameterisation.

Dorsey's own revision of RVI (S&C V.13:9): run the 1993 index over the
daily highs and again over the lows, and average the two. Proposed in
ta-lib-proposal-drafts#68.

Composed rather than fused. The proposal specifies one pass carrying two
copies of RVI's state; that body is expressible, but it carries two
window-start cursors and the streaming analyzer accepts exactly one
("extrema automaton: expected exactly one window-start variable"), so
fusing it would cost the function its streaming tier. Calling TA_RVI
twice keeps `flags: [stream]` for the reason KC's legs do -- each leg
streams as its own sub-handle -- at the cost of one scratch buffer.

The regression test's legs were chosen by what each can falsify, and the
discrimination was measured by mutating the generator input and
regenerating, not assumed:

  averaging dropped, high leg alone -> differential, composite and tie RED
  the two TA_RVI calls swapped      -> all green, and correctly: TA_RVI's
                                       write index trails its read index,
                                       so neither order can corrupt an
                                       aliased input
  lookback raised by one            -> degenerate RED, differential green
  scratch one element short         -> edges RED under AddressSanitizer

The differential leg rebuilds both legs from TA_STDDEV and TA_RMA rather
than from TA_RVI, so a defect shared by TA_RVI and TA_RVIR cannot hide in
it; the composite leg against two TA_RVI calls is structurally true
against this body and is there for a later fused rewrite.
Interfaces added, none removed or changed, so the generation goes
c+1:0:a+1 by the rule beside it in configure.ac and the soname stays
libta-lib.so.1. The twelve are TA_RVIR, TA_S_RVIR, TA_RVIR_Lookback and
the nine streaming entry points -- the tier the composed shape was
chosen to keep.
@kevinlincg

Copy link
Copy Markdown
Collaborator Author

CI is green on all seven checks, including the two I said I could not compile here: Generated Java and Generated C# both pass.

The one that failed was the one I had built locally. Generated C compiles came back red — not on a compiler error but on Public C ABI matches ABI.manifest: a new public function is an ABI addition, and I had not touched the manifest. Fixed in f9d793e: interfaces added and none removed or changed, so the generation goes c+1 : 0 : a+13:0:2 by the rule beside it in configure.ac, and the soname stays libta-lib.so.1.

Twelve entry points added: TA_RVIR, TA_S_RVIR, TA_RVIR_Lookback, and the nine streaming ones — which is the concrete thing the composed shape bought. A fused body would have shipped three.

Worth recording that check-abi on this machine reports EXPORTS UNCHECKED: no ELF shared library (Mach-O/PE not covered), so the export-table half of that gate only ever runs in CI. That is a second thing I could not have caught locally, on top of the two I declared.

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.

1 participant