Skip to content

feat(median): add TA_MEDIAN, the rolling median - #418

Merged
mario4tier merged 10 commits into
TA-Lib:devfrom
kevinlincg:feat/ta-median
Sep 22, 2026
Merged

mario4tier merged 10 commits into
TA-Lib:devfrom
kevinlincg:feat/ta-median

Conversation

@kevinlincg

Copy link
Copy Markdown
Collaborator

Implements TA_MEDIAN from the proposal card in ta-lib-proposal-drafts#73, which I claimed there before starting. That card has no open questions — the even-n mean is unanimous across NumPy, R, scipy and Excel, there is no original author to arbitrate against, and the fold-into-PERCENTILE alternative is closed by that function's own arity ruling.

The implementation is percentile.c with a different read

The machinery is borrowed unchanged: the window carried twice, ring by age and sorted by value, insertion-sort placement on the way in, shift-down removal on the way out, both hand-written CIRCBUFs as the card requires.

What changes is what comes out. At the output store sorted holds the window's other n−1 values and pos is where the incoming one belongs, so the k-th of the virtual full window is sorted[k], newValue, or sorted[k−1] depending which side of pos it falls on — no materialisation. Odd n is one such read at (n−1)/2; even n is two, at n/2−1 and n/2, averaged. The buffer stores stay below the output store exactly as they do in percentile.c, which is what keeps the streaming peek frame able to drop the state update.

The odd case is a branch, not (v + v) / 2.0. The arithmetic form is exact for any value this library is realistically handed — doubling moves the exponent with the mantissa untouched, halving moves it back — but it overflows to ±inf above DBL_MAX/2, and this function does not declare nan_inf_output. The condition is loop-invariant, so the branch costs nothing.

The strongest gate is in-tree and free

At odd n the median is the nearest-rank 50th percentile: ceil(50·n/100) is the central ordinal, and both functions read the same order statistic out of the same sorted window. So this is asserted against shipped TA_PERCENTILE(x, n, 50) with memcmp, not a tolerance. No oracle server, no capture.

That leg alone is satisfied by a body that just forwards to PERCENTILE — which is the one thing this function exists not to be. So the even-n leg asserts the two must differ, and it is mutation-validated:

mutation odd identity even differs goldens invented level exact shapes
even-n average → lower central value (i.e. the body becomes PERCENTILE) green RED — 0 of 7600 differ, against 7322 for the real body green green RED

The failure message names both possibilities rather than just the one, because a corpus with no window whose two central values differ would make leg 1 vacuous too.

One mutation deliberately did not go red, and I chased it down rather than leaving it as a weak gate. Flipping the placement scan's <= to < inserts a new value at the front of a run of equal values instead of the back — a real change to internal state. It changes no output at all: 14820 windows on both a 7-distinct-value series and a random walk, bit-identical. Equal members of a window are interchangeable, which is precisely the premise percentile.c's removal relies on when it identifies a value by value and carries no slot array. That measurement is now recorded at the site so the next person does not read the green as a hole.

Other legs: frozen numpy goldens at periods 7, 8 and 20 (asserted bitwise — the definition has no variant to pick); exact assertions on monotone and flat windows at both parities; in-place aliasing; and one that asserts the documented consequence rather than describing it — at even n the output reaches levels the series never traded at (13 such bars in the 8-period golden corpus), which is the deliberate opposite of PERCENTILE's design property.

Also in this PR

The card's "Also owed" item: percentile.md's alias list claimed "Rolling Median" unqualified, and the generated website/src/functions/percentile.md carried it live. True only for an odd window. Removed, with a CHANGELOG entry under Fixed. Worth landing whether or not MEDIAN itself ships.

Verified locally

  • python3 scripts/build.py generate — zero drift (generate twice, git diff unchanged)
  • ta_regtest — full run green; --function=MEDIAN exercises all seven legs with coverage counters (4620 odd-identity comparisons, 4640 even with 4396 differing, 88 golden, 13 invented levels, 29640 exact-shape, 3029 alias)
  • ta_codegen/generator — full suite green, no inventory needed updating
  • ta_codegen/output/rust — green
  • Java — build.py servers --language=java green, including NoPhantomIoTest (4674 checks) and StreamSmokeTest (5097)
  • ABI: twelve added entry points, nothing removed or changed, so c+1:0:a+1 → 3:0:2 and the soname stays libta-lib.so.1

Not verified locally

C# — this box's .NET SDK (7.0.200) cannot target net10.0, so that backend is only exercised by CI.

check-abi also reports EXPORTS UNCHECKED: no ELF shared library (Mach-O/PE not covered) on macOS, so the export-table half of that gate runs only in CI.

Note on the ABI line, shared with #413 and #417

All three of my open PRs bump TALIB_LIBRARY_VERSION to 3:0:2 and write function-count 2463. The version stays correct however many of them land — libtool's generation is per release, so several batches of additions inside unreleased 0.8.2 are one bump — but the count does not: git takes the identical count edit from each side without raising a conflict, leaving a manifest that lists more entry points than it claims. check-abi catches it (exit 1, naming the right number), so each PR after the first goes red on Public C ABI matches ABI.manifest and needs one scripts/build.py check-abi --update. Flagging it so that red reads as expected. Details on #417.

@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Filling in something these PRs left unstated rather than declared: ASan and UBSan. The proposal cards list them under free coverage; I neither ran them nor said I had not, which is the wrong half of that to get wrong.

Run now, via the tree's own scripts/build.py ta_regtest --sanitize (-fsanitize=address,undefined -fno-sanitize-recover=all), one build per branch:

function __asan symbols in the binary exit sanitizer errors
KURTOSIS (#417) 21192 0 0
MEDIAN (#418) 21184 0 0
CTI (#419) 21182 0 0

The symbol count is there because a sanitizer build that quietly lost its instrumentation reports zero errors in exactly the same way a clean one does.

And the green has a control. Widening MEDIAN's removal loop by one — while( j < lookbackTotal ) to while( j <= lookbackTotal ), so the shift-down reads one past the sorted window — gives ERROR: AddressSanitizer: stack-buffer-overflow, exit 134. Restored, it is back to exit 0 with zero errors. So the sanitizer is looking at these bodies, not past them.

Still not run here: C# (this box's .NET SDK cannot target net10.0), and the live pandas/Pine oracle arms on the cards' plans.

@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Withdrawing the C# caveat in this PR's description. It said that backend was exercised only by CI because this box's .NET SDK could not target net10.0. That was true when I wrote it and is no longer: I installed the SDK CI uses (dotnet-install.sh --channel 10.0, giving 10.0.401, into a scratch directory, no root) and built it.

C# now builds here, its own test suite passes, and the bitwise cross-language gate runs with all four arms:

branch function C# arm verdict
#413 RVIR 3370 cases, 0 mismatch BIT-IDENTICAL, zero tolerance
#417 KURTOSIS 1948 cases, 0 mismatch BIT-IDENTICAL, zero tolerance
#418 MEDIAN 1948 cases, 0 mismatch BIT-IDENTICAL, zero tolerance
#419 CTI 1948 cases, 0 mismatch BIT-IDENTICAL, zero tolerance

C#'s own suites on the same builds: BatchApiTest 111 checks, CoreBuilderTest 77, DivZeroTest 91, MetadataTest 4101, NoPhantomIoTest 420 — all pass.

The C# : N cases line is quoted rather than just the final PASS because a missing server makes the sweep skip that arm and still print PASS at the end. The sweep script treats a branch with no such line as MISSING, not as a pass.

Still not verified here: check-abi reports EXPORTS UNCHECKED: no ELF shared library (Mach-O/PE not covered) on macOS, so the export-table half of that gate remains CI-only — that one is a platform limit rather than a missing toolchain. The live pandas and Pine oracle arms named on the proposal cards are also still unrun.

@kevinlincg

Copy link
Copy Markdown
Collaborator Author

The pandas arm from the card, run. Not via ta_pandas_serve — ta-lib-oracles is not a repo I can reach — but directly against pandas 2.3.3 on this box, same corpus as the frozen numpy goldens.

pandas' convention was checked before trusting it: .rolling(4).median() on [1,2,3,4] gives 2.5, the mean of the two central values, which is the convention this function implements and the one the card calls unanimous.

Result: 88 values across periods 7, 8 and 20 — all 88 bit-identical. Worst relative difference exactly zero.

That is a second independent implementation agreeing bitwise, on a corpus whose 8-bar windows include 13 bars where the output is a level the series never traded at. numpy was the first; pandas reaches the same doubles by a different path (an incremental rolling accumulator rather than a per-window sort), which is the part worth having.

@kevinlincg
kevinlincg force-pushed the feat/ta-median branch 5 times, most recently from 518a5b3 to 6f77ba0 Compare September 16, 2026 18:56
@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Rebased onto 86917b38e.

While rebasing I found I had never run ta_regtest --codegen against my own
functions, so the post-cutover range-stability leg added in 52b984724 had
never seen them. It found a real defect in RVIR (#413): a missing UNSTABLE_MAP
row, failing by 5.8% across startIdx.

MEDIAN passes it:

no frozen-reference baseline (post-cutover): MEDIAN  [sweep skipped 1; all still bitwise-gated by VARIANT]
post-cutover range-stability verified: 1 of 1 (1 value-compared, 0 path-dependent: coherency only)

1 value-compared rather than 0 path-dependent is the part that matters:
the leg compared values, it did not classify the function as skippable.

The CI red on this PR is the Scan for secrets (Betterleaks) gate firing on a
generated FNV-1a hash seed, identical on all four of my PRs and not specific to
this one. Details and what I measured are in
#413 (comment) — I have not
touched the workflow or the generator over it.

@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Rebased onto dd2bef505, and closed the #427 gap on this file: its vectors now
reach the language servers.

Before this, everything this test pins was checked against in-process C and
nothing else. #427 lists sixteen files on dev with that shape; mine were the
same and would have arrived right behind them.

What is wired

Seven legs: odd, even, goldens, invented, monotone, flat, and the non-aliased call of the aliasing leg.

The aliased call of the aliasing leg is deliberately not sent: in-place
behaviour is a C-side memory property, not something the servers are asked to
reproduce.

Each call carries a vacuity floor on server_verify_comparisons(), the way
test_kdj.c does. server_verify() skips reject cases by design, so
"returned PASS" and "compared nothing" are otherwise the same observation.
Every wrapped site is a success case, which makes that skip path unreachable
and the count required to advance.

Measured, not assumed

The floor alone cannot tell you the comparison is real — it only fires when
pipes are live and nothing moved. So the discriminating check is a mutation:
hand the servers a different period than C used, and the run must fail.

SV FAIL [MEDIAN] (pipe 0, c): BITWISE mismatch vs in-process C
exit 22

Against the unmutated tree the same command is green with zero SV findings,
and a bare ta_regtest --function=MEDIAN is unaffected either way —
server_verify_active() is false with no pipes open.

What this buys specifically: a port that took the lower of the two central
members instead of their mean would pass every odd-window assertion in this
file untouched, and disagree with C on every even one. That is the defect the
odd/even split exists to catch, and until now it was only ever checked in one
language.

@kevinlincg
kevinlincg force-pushed the feat/ta-median branch 2 times, most recently from 3fd7eb5 to 5cf87db Compare September 17, 2026 04:53
@kevinlincg
kevinlincg force-pushed the feat/ta-median branch 12 times, most recently from 2ef418d to 216e886 Compare September 19, 2026 22:49
@kevinlincg
kevinlincg force-pushed the feat/ta-median branch 5 times, most recently from f6aa1ca to a0fa73c Compare September 21, 2026 18:55
@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Trimmed median.md to match the standard you set in efeda0b and d4586ca on RVI/RVIR. Pushed as 1158665.

Removed three Notes bullets. The "even-n mean is not a variant to choose" bullet is the same shape as the population-vs-sample standard deviation bullet you cut from rvi.md — it argues against a variant nobody ships. The odd-case branch and overflow reasoning, and the insertion-order note, are internals: both already live in median.c:112-134, and tie order among equal values is not observable in the output at all.

What I kept is the PERCENTILE contrast with the concrete 4-bar example, the consequence that an even period can emit a value the series never traded at, and the note that the period is not restricted to odd values.

This is my reading of your two commits, not something you asked for. Say the word on any bullet and I will put it back.

@kevinlincg
kevinlincg force-pushed the feat/ta-median branch 3 times, most recently from 70838a4 to 0d861c5 Compare September 22, 2026 02:48
@kevinlincg

Copy link
Copy Markdown
Collaborator Author

Rebased onto f9a708d. One thing from that commit is worth a question rather than a change.

You capped KURTOSIS at 10000 because the running sums drift past 1e-9 above ~3e4. MEDIAN declares [2, 100000] and that reason does not transfer — it has no running sums, and the output is a slot read, so it stays exact. What the top of its range does cost is time, because the by-value copy shifts O(n) entries per bar:

period us/bar value check
100 0.11 6/6 exact
1000 0.77 6/6 exact
10000 6.92 6/6 exact
100000 93.51 6/6 exact

150000 bars at period 100000 is 4.7s. Each row is checked against an independent median taken by sorting a copy of that bar's window, so the exactness is not the function checking itself.

So the question is whether MEDIAN's top should follow KURTOSIS's to 10000 for consistency of what the library promises to be usable at, or stay at 100000 because it is merely slow rather than wrong. I have not touched it — it is your call which of those the range is meant to express.

Also in this push: the MEDIAN test group's DO_TEST line and its declaration, both of which I had dropped resolving an earlier rebase. The suite stayed green the whole time because it simply was not running those legs — --function=MEDIAN said it matched no group and exited 0. Restored, and the group runs.

kevinlincg and others added 10 commits September 22, 2026 11:46
The middle order statistic of the trailing window: the central value at
odd optInTimePeriod, the mean of the two central values at even.
Proposed in ta-lib-proposal-drafts#73, which has no open questions --
the even-n mean is unanimous across NumPy, R, scipy and Excel, and there
is no original author to arbitrate against.

Borrowed wholesale from percentile.c: the window carried twice, by age
and by value, with insertion-sort placement and a shift-down removal.
What changes is the read. At the output store `sorted` holds the other
n-1 values and `pos` is where the incoming one belongs, so the k-th of
the virtual full window is sorted[k], newValue or sorted[k-1] depending
on which side of pos it falls. Odd n is one such read; even n is two,
averaged. The stores stay below the output store as they do there, which
is what lets the streaming peek frame drop the state update.

The odd case is a branch rather than (v + v) / 2.0. The arithmetic form
is exact for any value this library is realistically handed -- doubling
moves the exponent with the mantissa untouched and halving moves it back
-- but it overflows above DBL_MAX/2, and this function does not declare
nan_inf_output. The condition is loop-invariant.

The strongest test leg is in-tree and free: at odd n the median IS the
nearest-rank 50th percentile, so TA_MEDIAN must be bitwise equal to the
shipped TA_PERCENTILE(x, n, 50). Its non-vacuity companion is the even
case, which must DIFFER -- a body that forwarded to TA_PERCENTILE would
pass the first and fail the second. MEASURED with exactly that mutation:
0 of 7600 even windows differed, against 7322 for the real body.

One mutation deliberately did not go red, and the reason is recorded at
the site: flipping the placement scan's `<=` to `<` inserts a new value
at the front of a run of equal values instead of the back, and changes
no output at all -- 14820 windows on both a 7-distinct-value series and
a random walk, bit-identical. Equal members are interchangeable, which
is the premise the removal relies on rather than an accident.

Also fixes percentile.md's alias list, which claimed "Rolling Median"
unqualified and was live on ta-lib.org: true only for an odd window.
TA-Lib#421 moved the triple out of hand-editing: scripts/sync.py derives it from
ABI.released and the public headers. The hand-written bump this branch
carried is replaced by what that derivation produces.
Same gap issue TA-Lib#427 lists for sixteen files on dev: every vector here was
checked against in-process C and nothing else. The odd/even split, the frozen
goldens over the corpus and the invented-value leg are what pin the
even-window averaging rule, and a port that took the lower of the two central
members instead of their mean would pass every odd-window assertion untouched
while disagreeing with C on every even one.

Seven legs now call server_verify: odd, even, goldens, invented, monotone,
flat, and the non-aliased call of the aliasing leg. The aliased call is not
sent -- in-place behaviour is a C-side memory property.

Each call carries a vacuity floor on server_verify_comparisons(), as
test_kdj.c does, so "returned PASS" and "compared nothing" stay
distinguishable. Every site is a success case, which makes the skip-on-reject
path server_verify() takes unreachable here.

A bare ta_regtest run is unaffected: server_verify_active() is false with no
pipes open, and --function=MEDIAN stays green.
server_verify_comparisons() also counts server_verify_lookback_parity(),
which compares no number. A leg that ran only that would satisfy a floor on
the total while having compared nothing, so the floor was one step weaker
than the thing it claims to prove.

server_verify_value_comparisons() is the subset that compared output values.
DO_TEST's own floor (TA-Lib#427) reads it for the same reason; this file now
agrees with it.

No behaviour change on the current call sites -- every one of them routes a
value comparison, so both counters advance together today. The point is that
the assertion now cannot be satisfied by a comparison that checks no value.
Products only, regenerated against dev's folded batch tier (740eec6).
The enum entry was dropped resolving a conflict in ta_error_number.h,
which left test_median.c referring to an identifier that no longer
existed. It takes 1683, which is still free on dev.
Both were dropped resolving an earlier rebase, which left the suite green
while running none of MEDIAN's legs: --function=MEDIAN reported that it
matched no group and still exited 0. Also restores the CHANGELOG entry and
regenerates the id file, where MEDIAN's four guards take 431..434.
MEDIAN shifts a sorted copy of its window on every bar, so its cost grows
with the period exactly as PERCENTILE's does (404501f): measured here,
6.9us per bar at 10000 against 93.5us at 100000, while the values stay
exact at both. It has not shipped, so no CHANGELOG entry.

A new leg pins 10000 as accepted and 10001 as rejected, in the lookback and
in the call; reverting the range to 100000 fails it.
@kevinlincg

Copy link
Copy Markdown
Collaborator Author

404501f answers the question I asked, so I applied it rather than wait: MEDIAN's period top is now 10000, pushed as 6deff1c.

Your rule there is cost proportional to the period on every bar, and MEDIAN is the same machinery as PERCENTILE — it shifts a sorted copy of its window. Measured on this box, 6.9us per bar at 10000 against 93.5us at 100000, the same shape as your 3 and 33. The values stay exact at both, so this is a cost cap, not a correctness one.

A new leg pins 10000 as accepted and 10001 as rejected, in the lookback and in the call, following the shape of your PERCENTILE edges. It is mutation-checked rather than merely green: reverting the range to 100000 fails it with lookback 10000 at period 10001, expected -1, exit 120.

No CHANGELOG entry, since MEDIAN has not shipped — that is the distinction your PERCENTILE commit drew, and it puts MEDIAN on the KURTOSIS side of it.

If you would rather MEDIAN keep 100000 because being merely slow is not the same as being wrong, this is one line in the yaml plus the leg.

@mario4tier
mario4tier merged commit 589d3ea into TA-Lib:dev Sep 22, 2026
7 checks passed
@kevinlincg
kevinlincg deleted the feat/ta-median branch September 24, 2026 09:38
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