feat(median): add TA_MEDIAN, the rolling median - #418
Conversation
|
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
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 — Still not run here: C# (this box's .NET SDK cannot target |
|
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 C# now builds here, its own test suite passes, and the bitwise cross-language gate runs with all four arms:
C#'s own suites on the same builds: The Still not verified here: |
|
The pandas arm from the card, run. Not via pandas' convention was checked before trusting it: 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. |
518a5b3 to
6f77ba0
Compare
|
Rebased onto While rebasing I found I had never run MEDIAN passes it:
The CI red on this PR is the |
6f77ba0 to
b68fef8
Compare
|
Rebased onto Before this, everything this test pins was checked against in-process C and What is wiredSeven 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 Each call carries a vacuity floor on Measured, not assumedThe floor alone cannot tell you the comparison is real — it only fires when Against the unmutated tree the same command is green with zero SV findings, What this buys specifically: a port that took the lower of the two central |
3fd7eb5 to
5cf87db
Compare
5cf87db to
c004e05
Compare
2ef418d to
216e886
Compare
f6aa1ca to
a0fa73c
Compare
|
Trimmed Removed three Notes bullets. The "even- What I kept is the 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. |
70838a4 to
0d861c5
Compare
|
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
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 |
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.
0d861c5 to
6deff1c
Compare
|
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 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. |
Implements
TA_MEDIANfrom the proposal card in ta-lib-proposal-drafts#73, which I claimed there before starting. That card has no open questions — the even-nmean is unanimous across NumPy, R, scipy and Excel, there is no original author to arbitrate against, and the fold-into-PERCENTILEalternative is closed by that function's own arity ruling.The implementation is
percentile.cwith a different readThe machinery is borrowed unchanged: the window carried twice,
ringby age andsortedby value, insertion-sort placement on the way in, shift-down removal on the way out, both hand-writtenCIRCBUFs as the card requires.What changes is what comes out. At the output store
sortedholds the window's othern−1values andposis where the incoming one belongs, so the k-th of the virtual full window issorted[k],newValue, orsorted[k−1]depending which side ofposit falls on — no materialisation. Oddnis one such read at(n−1)/2; evennis two, atn/2−1andn/2, averaged. The buffer stores stay below the output store exactly as they do inpercentile.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 aboveDBL_MAX/2, and this function does not declarenan_inf_output. The condition is loop-invariant, so the branch costs nothing.The strongest gate is in-tree and free
At odd
nthe 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 shippedTA_PERCENTILE(x, n, 50)withmemcmp, 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-nleg asserts the two must differ, and it is mutation-validated:naverage → lower central value (i.e. the body becomesPERCENTILE)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 premisepercentile.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
nthe output reaches levels the series never traded at (13 such bars in the 8-period golden corpus), which is the deliberate opposite ofPERCENTILE's design property.Also in this PR
The card's "Also owed" item:
percentile.md's alias list claimed "Rolling Median" unqualified, and the generatedwebsite/src/functions/percentile.mdcarried 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 diffunchanged)ta_regtest— full run green;--function=MEDIANexercises 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 updatingta_codegen/output/rust— greenbuild.py servers --language=javagreen, includingNoPhantomIoTest(4674 checks) andStreamSmokeTest(5097)c+1:0:a+1→3:0:2and the soname stayslibta-lib.so.1Not verified locally
C# — this box's .NET SDK (7.0.200) cannot target
net10.0, so that backend is only exercised by CI.check-abialso reportsEXPORTS 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_VERSIONto3:0:2and writefunction-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-abicatches it (exit 1, naming the right number), so each PR after the first goes red onPublic C ABI matches ABI.manifestand needs onescripts/build.py check-abi --update. Flagging it so that red reads as expected. Details on #417.