Skip to content

feat: KVO, Klinger Volume Oscillator (#484) - #549

Merged
mario4tier merged 41 commits into
TA-Lib:devfrom
kevinlincg:feat-kvo
Oct 15, 2026
Merged

mario4tier merged 41 commits into
TA-Lib:devfrom
kevinlincg:feat-kvo

Conversation

@kevinlincg

Copy link
Copy Markdown
Collaborator

Closes #484. Adds TA_KVO, Klinger Volume Oscillator (Stephen J. Klinger, TASC December 1997), with the 1997 factor |2*(dm/cm) - 1|, the article's tie rule, and three EMA seeded on one anchor bar. Ruled on the card: converging, no path_dependent, no id of its own -- it inherits TA_FUNC_UNST_EMA as ADOSC does.

What is in it

  • ta_codegen/input/kvo/ -- body, yaml, md; ta_auto_stabilization_kvo in the helpers, registered in stability_suite.rs as inheriting EMA and in the fma inventory.
  • test_kvo.c -- golden (60-digit reference, 1e-12), two analytic identities (fast == slow is exactly zero; fast <-> slow negates bit for bit), a built tie series, lookback at four unstable periods, output aliasing, and the range sweep as TA_STABLE_CONVERGING. Every comparison count is pinned; server_verify on the golden leg.
  • Range sweep grace -- KVO is a small difference of two large averages (mean |KVO| is 7% of either on the corpus), so one seed error is ~14x the output and fades at 54/56 per bar: 100 to 160 bars of grace fail, 175 holds (bars 230 to 251 compared, worst 0.13 of tolerance), 197 and up compare nothing. The grace is a parameter of doRangeTestMulti, keyed on the function name in perFuncGrace[] beside perFuncTol; test_kvo.c reads the same table.
  • A floor, its own commit -- a TA_STABLE_CONVERGING sweep that compares no value fails with TA_TESTUTIL_DRT_NO_COMPARISON (166), counted at the comparison. Red control: KVO at a grace of 200.
    • It trips STC in the codegen sweep (reported on (KVO) Klinger Volume Oscillator #484, not waived): at the defaults TA_STC_Lookback is 67, so 184 bars are reachable against its own grace of 200; the sweep compared nothing and read as PASS. test_stc.c sweeps at 5/13/4 and is not affected. STC is unchanged here.
  • Auto-Stabilization -- awCheckOffset holds KVO's count with the file's copy of the rule (an EMA at (4K+23)/3 e-folds at the longest of three periods, less the inherited count); the census runs KVO by name so the nightly sample (Nightly sampled check that Auto-Stabilization counts and the 1e-9 target still hold #540) reads it; census_table.tsv gains eight rows calibrated on one host (spread 0, MARGIN_FLOOR ceilings); rZ2 names KVO in the specification and spec-conformance.md. Measured margin for the count review: worst need 335 of 578 at PREC_4, 587 of 908 at PREC_8 at the defaults; no start of 2 x 20000 needs more. No PREC_8 floor row: two starts are bit-identical over the tail at a setting of 0.

Checked

  • build.py generate then regen-check: zero drift. Generator cargo test --release green.
  • ta_regtest (all hand-written groups) green; --function=KVO, --function=AUTO green.
  • --codegen --function=KVO: C, Rust, Java, C# pass. --xlang-hash --function=KVO: 0 mismatches / 6948 cases per language.
  • --codegen over every function: green except STC, as above.
  • scripts/auto_stabilization_sample.py: 288 rows, 576 shares and 576 replays held.
  • Red controls: grace 200 -> 166; rule copy at (4K+22)/3 -> awCheckOffset fails on every KVO vector; helper at a quarter or at zero -> awCheckOffset fails. The two-starts leg does not fail at zero (EMA's own 275 bars already converge KVO on its six starts and three series), so the size of the count is held by the census sample, not by that leg.

Each bar's volume is signed by the direction of its high-low-close sum and
weighted by where the bar's own range sits inside the cumulative range of
the current trend run; the resulting volume force is smoothed twice and
differenced, and the trigger is a third smoothing of that. Stephen J.
Klinger, Technical Analysis of Stocks & Commodities V15:12 (December 1997).

The factor is the 1997 form |2*(dm/cm) - 1| (TA-Lib#484, form A), which the
December 1997 Traders' Tips all print. It is not LEAN's, and the ruling
gives up LEAN parity for it.

Three things the shape forces:

- The trend is HELD when the high-low-close sum repeats, which is the
  article's own tenet. It is one line, and a corpus with few ties barely
  exercises it: MEASURED, the committed 252-bar corpus holds exactly one
  tie, at bar 187, and reading it as +1, or as a reversal, moves KVO by up
  to 1.0e7 from the next bar on. Cheap to get wrong, not cheap to notice.
- The cumulative range is reset only by a trend change, so two calls
  starting at different bars can carry different state into the same bar.
  That is what `path_dependent` declares.
- All three smoothings are seeded raw from the volume force of the same
  anchor bar, not from a simple average of their own first inputs, so ONE
  ema_lookback of the longest period covers all three and the lookback is
  that plus the reference bar.

KVO takes no TA_FUNC_UNST id of its own: every 1997 transcription writes
the three smoothings as the platform's EMA call, so it inherits
TA_FUNC_UNST_EMA. Its UNSTABLE_MAP row is there for the stream K-leg
rather than the range envelope, as SUPERTREND's is: `path_dependent`
makes stability_class() answer SKIP before the id is consulted.
…-Lib#484)

KVO is not a chain of shipped calls, so there is no composition gate to
borrow. Two identities stand in its place, and both are worth more than a
golden table because neither depends on a reference value:

- fast == slow makes the two averages the same series, so the oscillator
  is EXACTLY zero on every bar and the trigger, seeded on that zero, never
  leaves it. Measured: 232 of 232 bars.
- Swapping fast and slow negates the output BIT FOR BIT, trigger
  included: a - b = -(b - a) is exact in IEEE and the trigger's recursion
  commutes with negation. Measured: 197 of 197 bars.

Together they pin KVO = EMA(vf, fast) - EMA(vf, slow) and the trigger's
dependence on it. An implementation that crossed the two averages, or let
the trigger drift off the oscillator, fails one of them on every bar.

The tie leg does not rest on the corpus, which holds one tie: it builds a
series where every third bar repeats the sum while carrying a different
range, so a mis-read tie changes the trend sign and resets the cumulative
range at once. Its tie count is asserted, because a series that stopped
containing ties would leave the leg green and empty. The prices are exact
decimal values, so the frozen rows carry no dependence on the platform's
libm.

The golden leg holds the 1997 factor, from a 60-digit evaluation over the
committed corpus; the library reproduces those 28 rows to 5.4e-15 by
|a-b|/max(|b|,1), so the tolerance is 1e-12.
The four registration lists are resolved as a union: dev carries PSO and
FISHER, this branch carries kvo, and taking either side alone would drop one
of them without failing a build. Roll-called after the merge.

Generated files regenerated, not merged by hand.
The merge took dev's function-count while both sides' entries came through,
so the declared count and the actual function lines disagreed (3043 against
3056). scripts/sync.py on a clean tree, with a worktree holding dev; abi.py
check is clean afterwards.
# Conflicts:
#	include/ta_common.h
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
# Conflicts:
#	ABI.manifest
#	include/ta_common.h
…jects

The Auto-Stabilization suite that landed on dev decides this card's open
question by measurement. With flags: [stream, path_dependent] it reports,
at the defaults and at tripled periods:

  auto-stabilization: KVO defaults: meets the converging criterion on
  every series and start: the path_dependent flag is wrong
  auto-stabilization: KVO periods tripled: meets the converging criterion
  on every series and start: the path_dependent flag is wrong
  Failed group [UNSTABLE,LOOKBACK,AUTO] with code=1720

Removing the flag and regenerating turns the whole suite green. Nothing
else changed between the two runs, and the regtest binary was rebuilt
(bc79b8d8a4 -> 40e92db9af), so the verdict belongs to the flag.

KVO now reads "Initial Unstable Period, inherited from EMA", the same
shape as ADOSC after its reclassification. Whether that inherited count
needs an adjustment, and in what form, is the question left on TA-Lib#484.
# Conflicts:
#	include/ta_common.h
#	src/ta_abstract/ta_func_api.c
#	ta_codegen/output/csharp/library/src/metadata/CatalogFacts.g.cs
#	ta_codegen/output/java/library/src/main/java/io/github/talib/metadata/FunctionDescription.java
KVO inherits EMA's Auto-Stabilization count through ema_lookback and
carried no adjustment of its own, so the count it discards was short of
what it needs. Measured with docs/studies/auto-stabilization's probe,
40000 bars, three series, worst case over series and starts:

                    discarded  need   discarded  need
                        PREC_4 PREC_4     PREC_8 PREC_8
  defaults 34/55/13        275   473        523    764
  102/165/39               825  1330       1568   2098

The shortfall is not a constant: 198 bars at the defaults and 505 at
tripled periods, and need/discarded climbs from 1.33 at 2/3/2 to 2.04 at
174/233/9 over a 36-point grid. Holding the slow period fixed shows why
it moves -- the need rises as the fast period approaches the slow one,
where the difference of the two averages shrinks while the seed error
does not. That is the opposite end from where ADOSC's rule puts its
weight, and ADOSC's rule applied unchanged is short at 26 of 36 points.

ta_auto_stabilization_kvo raises the level instead: K_eff = (4K + 23)/3,
which is 21 at PREC_4 and 33 at PREC_8. Over the same 36 points it has
no violation, the thinnest margin being +12 at 2/3/2. After the change
the defaults discard 578 and 908 against a need of 473 and 764.

Sized on the zigzag series, the worst of the three at every setting.
# Conflicts:
#	ABI.manifest
#	include/ta_common.h
#	src/ta_abstract/ta_func_api.c
#	src/tools/ta_regtest/Makefile.am
#	src/tools/ta_regtest/ta_meta_frame.h
#	src/tools/ta_regtest/ta_regtest.c
#	src/tools/ta_regtest/ta_stream_frame.h
#	src/tools/ta_regtest/ta_test_func.h
#	src/tools/ta_regtest/ta_variant_frame.h
#	ta_codegen/output/csharp/library/src/metadata/CatalogFacts.g.cs
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/library/src/main/java/io/github/talib/metadata/FunctionDescription.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
#	ta_codegen/output/rust/library/src/abstract_api.rs
#	ta_codegen/output/rust/library/src/ta_func/no_phantom_io.rs
The emitter fuses KVO, so enums_suite::rust_fma_dispatch_fires_for_exactly_the_fusing_functions
read it in the emitted set and not in the pinned one. The inventory feeds the
suites, not the emitter: regenerating changes no product.
# Conflicts:
#	CMakeLists.txt
#	include/ta_common.h
#	src/ta_abstract/ta_func_api.c
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/library/src/main/java/io/github/talib/metadata/FunctionDescription.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
# Conflicts:
#	include/ta_common.h
#	src/ta_abstract/ta_func_api.c
#	src/tools/ta_regtest/ta_regtest.c
#	src/tools/ta_regtest/ta_test_func.h
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/library/src/main/java/io/github/talib/metadata/FunctionDescription.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
# Conflicts:
#	include/ta_common.h
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
# Conflicts:
#	include/ta_common.h
#	src/ta_abstract/ta_func_api.c
#	ta_codegen/output/csharp/library/src/metadata/CatalogFacts.g.cs
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/library/src/main/java/io/github/talib/metadata/FunctionDescription.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
# Conflicts:
#	CMakeLists.txt
#	include/ta_common.h
#	src/ta_abstract/ta_func_api.c
#	src/tools/ta_regtest/Makefile.am
#	src/tools/ta_regtest/ta_regtest.c
#	src/tools/ta_regtest/ta_test_func.h
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/library/src/main/java/io/github/talib/metadata/FunctionDescription.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
# Conflicts:
#	include/ta_common.h
#	ta_codegen/output/java/library/src/main/java/io/github/talib/BuildStamp.java
#	ta_codegen/output/java/tools/TaCodegenServe.java
… on the name (TA-Lib#484)

KVO is classed TA_STABLE_CONVERGING on the EMA id it inherits, as ADOSC is,
not TA_STABLE_SKIP: the cumulative range two starts carry is reset for both
by the first trend change after both, and what remains is the seed error of
three EMA seeded on one anchor, fading at the slow average's rate (54/56 per
bar at the defaults). The earlier claim that it never converges read a
moving-bar table and was wrong; the body needs no change.

It needs a longer grace than EMA's own because it is a small difference of
two large averages: on the corpus mean |KVO| is 7% of either, so one seed
error is ~14x the output and outlives the 100-bar grace. MEASURED on the
252-bar corpus, only periodToIgnore changed: 100 to 160 fail (rc=161), 175
compares bars 230 to 251 at worst 0.13 of the tolerance, 190 compares 590
values, 197 and up compare nothing (252 - 55 - 1 = 196).

doRangeTestMulti takes the grace as a parameter (0 = the id's own) so it is
keyed on the function name beside perFuncTol in test_codegen.c, not on a new
unstable id; test_kvo.c reads the same table through codegen_range_grace()
so the hand test and the codegen sweep cannot drift apart. STC and DOSC pass
0 and are unchanged. The UNSTABLE_MAP comment and kvo.md no longer say the
function is path-dependent.
…A-Lib#484)

The convergence envelope leaves a grace of bars uncompared, and nothing is
reachable past MAX_RANGE_SIZE - lookback - 1: a grace at least that long, or
a lookback that leaves fewer bars than the grace, let the sweep pass without
holding a single value to its tolerance. KVO at a grace of 200 did exactly
that on the 252-bar corpus (196 reachable bars) and read as rc=0.

doRangeTestMulti now counts at the comparison -- a real value past the
grace, bit-identical or not, and every integer value that is not skipped --
and fails a TA_STABLE_CONVERGING sweep whose count is 0 with
TA_TESTUTIL_DRT_NO_COMPARISON (166). For CONVERGING the grace is decided
before the bit-identical and near-zero early returns, so a bar inside the
grace is not counted; the pass/fail answer of every branch is unchanged.

MEASURED: KVO at a grace of 200 fails with 166 ("compared no value"), at 175
passes; the whole hand-written ta_regtest passes. The --codegen sweep trips
ONE shipped function, STC, reported on TA-Lib#484 and not waived here: at its
defaults TA_STC_Lookback is 67, so 184 bars are reachable against its own
grace of 200, and its codegen range sweep had compared nothing and read as
PASS. test_stc.c sweeps at 5/13/4 and is not affected. Every other function
passes the sweep.
…A-Lib#484)

The count ta_auto_stabilization_kvo carries on the EMA id it inherits --
an EMA at (4K+23)/3 e-folds at the longest of its three periods, less the
EMA count it inherits there, so 21 and 33 e-folds at the two levels -- is
now held the way ADOSC's is:

- test_auto_stabilization.c: this file's copy of the rule in awCheckOffset,
  with a third integer period read so the longest of fast, slow and signal
  is the one sized, and a "signal longest" vector (10/20/40) so the trigger
  is the longest at least once. rZ2 in docs/spec-conformance.md and the
  specification name KVO beside CKSP and ADOSC.
- auto_stabilization_census.c lists KVO: a function that owns no id carries
  no TA_FUNC_FLG_UNST_PER, so it is run by name as the owners are, and the
  nightly sample (TA-Lib#540) reads it. census_table.tsv gains its eight rows,
  calibrated on ONE host (macOS, Intel): two seeds of 20000 trials for the
  needs and worst trials, the sample for the shares. With one host the
  spread is 0 and the share ceilings carry MARGIN_FLOOR only; a calibration
  on the usual hosts would replace them. No start needs more than the count
  at either level, and the worst need is 335 of 578 at PREC_4 and 587 of
  908 at PREC_8 at the defaults, which is the margin the count is reviewed
  on.
- No PREC_8 floor row: TA_AUTO_STABILIZATION_FLOORS=1 prints none for KVO
  because two starts are bit-identical over the last quarter of every
  series at a setting of 0, so the floor of 0 is the measured one.

MEASURED red: the file's copy of the rule at (4K+22)/3 fails awCheckOffset
on every KVO vector, and so does the helper cut to a quarter or to zero.
The two-starts leg does NOT fail at zero: on this file's six starts and
three series the EMA count alone (275 at PREC_4) already converges KVO to
the level's digits, so what holds the SIZE of the count is the census
sample (worst need 335 of 20000 x 2 trials at PREC_4), not this leg. Said
here so the count review reads the right gate.
@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Measurements for the STC finding above, so the ruling has numbers. Same corpus, same codegen range sweep, STC at its defaults 23/50/10 (lookback 67, so 184 bars are reachable past it). "worst" is the largest difference / tolerance seen past the grace, over all seeds and unstable periods the sweep tries; the four pass rows are the four backends.

grace result values compared worst / tolerance
100 fail, 21.9% off at bar 62 past grace — —
120 fail, 10.5% — —
140 fail, 35.4% — —
150 fail, 10.1% — —
152, 154 fail — 1.046
156 pass 29700–39600 0.93
158 pass 28400–39800 0.66
160 pass 28300–33400 0.44
165 pass 17200–20100 0.075
170 pass 9000–9800 —
175 pass 3800–4500 —
180 pass 660–735 —
183 pass 67–82 —
184 and up compares nothing (the new floor, 166) 0 —

Control: KVO at its 175 on the same probe reads worst 0.128, the figure in the perFuncGrace comment, so the probe agrees with the earlier measurement.

So at the defaults the transient is gone by 156 and the margin is KVO-like from 165 on. Which of the three options to take is still yours; STC is unchanged on this branch. If it is a shorter grace, 165 keyed on the name in perFuncGrace would be the one-line change, with the T3 and SWAK_BP cases left at 200 (T3 lookback 24 and SWAK_BP 0 both clear 200 on this corpus).

@mario4tier
mario4tier merged commit 7d04c2f into TA-Lib:dev Oct 15, 2026
10 checks passed
mario4tier added a commit that referenced this pull request Oct 15, 2026
… full alias and Auto coverage (#484)

The CONVERGING floor from #549 turned the full --codegen run red on STC: at
its defaults the lookback is 67 and the id's grace of 200 leaves no bar
reachable on the 252-bar corpus. STC takes a 175-bar grace beside KVO's:
150 fails on every seed, 175 holds on 48.

The grace per unstable id lived in two switches in test_util.c; one
function now answers for both. The KVO aliasing leg puts each output on
each of the four inputs (8 of 8 pairs, not 4), and the Auto offset check
gets a vector where the fast period is the longest. The KVO helper's
comment no longer claims the count tracks the closest pair, and VIDYA's
saturation note is back on VIDYA.
mario4tier added a commit that referenced this pull request Oct 15, 2026
…cts (#484)

The three averages were spelled (x - prev) * k + prev, which fuses to a
subtraction and an FMA on the loop-carried chain. TA_EMA and ADOSC spell
k * x + beta * prev with k + beta exactly 1.0, which leaves one FMA on the
chain. The trend and the cumulative range were data-dependent branches;
they are now selects, and the trend is carried as +-1.0 so no conversion
is left in the loop.

Values move in the last bits only. Against an exact-rational evaluation
of the corpus the worst error is 1.6e-15 of scale at 34/55/13 (9.7e-16
before) and 7.1e-16 at 10/20/5 (4.7e-16 before); every golden row holds
at 1e-12. Tulip's kvo agrees to 2.0e-15 of scale on every bar at 34/55,
8.9e-16 at 10/20 and 5.5e-16 at 5/11.

kvo.md: the zero on the seed bar is never output, and the seed error
fades at the longest period's rate. The change-history row credits the
author of #549.
@kevinlincg
kevinlincg deleted the feat-kvo branch October 15, 2026 05:35
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