@pond-ts/react 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/API.md +211 -34
  2. package/CHANGELOG.md +1111 -1
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
9
9
  include new features and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.65.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.67.0...HEAD
12
+ [0.67.0]: https://github.com/pond-ts/pond/compare/v0.66.0...v0.67.0
13
+ [0.66.0]: https://github.com/pond-ts/pond/compare/v0.65.0...v0.66.0
12
14
  [0.65.0]: https://github.com/pond-ts/pond/compare/v0.64.0...v0.65.0
13
15
  [0.64.0]: https://github.com/pond-ts/pond/compare/v0.63.0...v0.64.0
14
16
  [0.63.0]: https://github.com/pond-ts/pond/compare/v0.62.0...v0.63.0
@@ -68,6 +70,1114 @@ include new features and type-level changes; patch bumps are strictly additive.
68
70
 
69
71
  ## [Unreleased]
70
72
 
73
+ ## [0.67.0] — 2026-09-11
74
+
75
+ ### Added
76
+
77
+ - `@pond-ts/financial`: **a runtime study catalog** — `import { STUDIES } from
78
+ '@pond-ts/financial/catalog'`. One `StudyDescriptor` per study (all 109
79
+ fluent methods): the columns it reads (`inputs`, with defaults — absent
80
+ means required, as a `benchmark` is), its numeric and menu options
81
+ (`params`: `kind`, `default` or an `example` for a required one,
82
+ `min`/`max` — inclusive, and only where the study validates a constant
83
+ bound — and a `suggest` range a control is drawn on, legal throughout at
84
+ the other options' defaults), how it names what it appends (`naming`: `output` or `prefix`
85
+ and the default), the columns it appends (`outputs`, each with a `unit`
86
+ from a closed vocabulary — `inherit` / `delta` / `percent` / `ratio` /
87
+ `signal` / `volume` / `index` / `bars` — that answers whether the column
88
+ may share the source's axis), a `family` and one-line `summary` for a
89
+ picker, `anchor: 'session' | 'time'` for the two session-anchored studies
90
+ and `anchoredVwap` (an input the consumer supplies from context, not a
91
+ control), `optional: true` for an option whose absence is a switch rather
92
+ than a value (`balanceOfPower`'s `period`) with `requires` for a menu
93
+ that is only legal alongside it, and `run`. The shape is modelled on `@pond-ts/process`'s `OpDef` (`role`, `id`,
94
+ `unit`, `suggest` are its words) so a registry maps it rather than
95
+ interprets it — with two deliberate differences: an input carries its
96
+ `default`, and a required option carries an `example` where process
97
+ requires a `default`. Asked for by a consumer that was otherwise
98
+ hand-transcribing ~400 facts from `.d.ts` files and re-checking them per
99
+ release. Guarded two ways so it cannot drift from the studies:
100
+ `defineStudy<Options>()` classifies every key of the options interface at
101
+ compile time and rejects a missed or misspelt key, a default claimed on a
102
+ required option, a menu value outside the union, a menu `of` that omits a
103
+ member, or an option shape it does not know; and `test/catalog.test.ts` runs every descriptor against its
104
+ study — the appended columns are exactly those declared and every one
105
+ has a value on the fixture, stating every default explicitly changes
106
+ nothing, every menu value and both `suggest` endpoints run, a declared
107
+ `min`/`max` is accepted and one past it throws, and the catalog is exactly
108
+ the set of fluent methods. A separate subpath, so the main entry's tree-shaking is untouched.
109
+ The `API map` workflow now guards `catalog/index.ts` too.
110
+ - `@pond-ts/financial`: **the session-anchored studies** (corpus §6.6 / §6.9 —
111
+ the **G4** pair the trading calendar was gating). Both take the session as a
112
+ first-class input through one shared option shape, `SessionAnchorOptions`:
113
+ exactly one of **`sessions`** (a `TradingCalendar` or a `Session[]` — the
114
+ primary door — a calendar is narrowed with `sessionsInRange`, an explicit
115
+ list validated per call — and walked once, `O(N +
116
+ sessions)`) or **`session`** (the name of a session-id column, what
117
+ `TradingCalendar.tagSessions` appends — the door for a series already
118
+ partitioned by session), plus `stamped: 'open' | 'close'` on the calendar
119
+ door. A bar in **closed time** — between sessions, a weekend print on a 24/7
120
+ feed, outside the schedule — reads `undefined` in both studies. Both doors
121
+ run the **same** `sessionIdValues` walk `tagSessions` now runs, so they are
122
+ the same anchoring by construction, and a test pins the two routes equal
123
+ under both stamp conventions.
124
+ - **`sessionVwap({ sessions | session, stamped, high, low, close, volume,
125
+ output = 'svwap' })`** — the VWAP an intraday desk means: `Σ tp·vol / Σ vol`
126
+ accumulated from each session's open and **reset at the next**. This is the
127
+ third VWAP form `vwap` named and deliberately left open. It composes on
128
+ `anchoredVwap`'s arithmetic literally rather than by resemblance — both
129
+ studies now call one `anchoredVwapValues(typical, volume, anchors)` kernel
130
+ and differ only in what they pass as the anchor group. An interior gap ends
131
+ **that session's** line (`obv`'s rule; the two sums are blanked together so
132
+ a bar with volume but a missing `high` cannot bias the average) and the
133
+ next session open re-seeds — the reset is the recovery `anchoredVwap` makes
134
+ the caller do by hand. `Σ vol = 0` → `undefined`, live at the output.
135
+ - **`pivotPoints({ sessions | session, stamped, method = 'standard', high,
136
+ low, close, prefix = 'pp' })`** — each session's support/resistance ladder
137
+ from the **previous session's** aggregate high / low / close, held flat
138
+ across the session. Four formula sets, all reading the same three inputs
139
+ and differing in constants: `'standard'` (floor-trader), `'fibonacci'`
140
+ (0.382 / 0.618 / 1.000 of the range), `'woodie'` (the standard ladder over
141
+ the close-weighted centre `(H + L + 2C)/4`) and `'camarilla'` (Nick Scott's
142
+ 1.1/12, 1.1/6, 1.1/4, 1.1/2, measured from the **close**, not the pivot).
143
+ **The column set follows `method`**: seven columns (`${prefix}Pivot`,
144
+ `R1–R3`, `S1–S3`) for the first three and **nine** for Camarilla, which is
145
+ the only set defining a fourth pair — the return type is conditional on
146
+ `method` rather than shipping three methods with two permanently-`undefined`
147
+ columns. The first session with bars and every closed-time bar read
148
+ `undefined`; "previous session" means the previous session **with bars in
149
+ this series**, not the previous entry on the calendar.
150
+ - Two deliberate definition deltas, both documented on the study: Woodie's
151
+ ships the previous-**close** centre `(H + L + 2C)/4` rather than the
152
+ current-open variant also in circulation, and Camarilla's levels are
153
+ centred on the close rather than on the pivot (which is the definition, and
154
+ is what makes its ladder asymmetric about `ppPivot`).
155
+ - Oracle: five new cases on a new **session-keyed** input
156
+ (`input.sessionTimes`) — the same 80 OHLCV bars re-keyed onto a real
157
+ 09:30–16:00 America/New_York 30-minute grid over six sessions, with two
158
+ bars in no session. The references are pandas `groupby`-`cumsum` and
159
+ `groupby().agg().shift(1).reindex()`, a different formulation from our
160
+ sequential loops; the vitest side rebuilds the calendar from the same rules
161
+ rather than from a table, so a Temporal/`zoneinfo` disagreement about a
162
+ session boundary fails the case rather than hiding.
163
+ - `@pond-ts/financial`: **Ichimoku Cloud and ZigZag** (corpus §6.4) — the two
164
+ most-used studies left in the corpus, each shipped in the form that needs no
165
+ core change. Both take the uniform shape (bar columns plus a `prefix`,
166
+ bar-count periods, a length-preserving per-column warm-up, a fluent method)
167
+ and both have pandas oracle cases. One internal kernel helper rides with
168
+ them: **`rollingBarExtremesValues`** (the max of one array beside the min of
169
+ another over a strict window, in one deque walk), which the perf check
170
+ surfaced — Ichimoku on the single-array door spent 414 ms of its ~404 ms at
171
+ 1M bars in six deque passes where three suffice; on the package bench the
172
+ study went 433.6 → 280.7 ms at 1M.
173
+ - **`ichimoku({ conversionPeriod = 9, basePeriod = 26, spanBPeriod = 52,
174
+ displacement = 26, high, low, close, prefix = 'ichi' })`** — Hosoda's five
175
+ lines as `ichiTenkan` / `ichiKijun` / `ichiSenkouA` / `ichiSenkouB` /
176
+ `ichiChikou`, each the midpoint of the highest `high` and lowest `low` of
177
+ its own window (the Chikou span is the close). Per-column warm-up 8 / 25 /
178
+ 25 / 51 / 0. **`displacement` changes no value**: the study keys every
179
+ column to the bar it is _computed from_ and shifts nothing — the forward
180
+ spans have no rows past the last bar to land on (assessment gap **G5**),
181
+ and a pre-shifted Chikou would be a look-ahead column, the one thing no
182
+ other column in the package is. The new **`ichimokuOffsets(options)`**
183
+ returns the per-column x-offset in bars (`+displacement` on the two spans,
184
+ `−displacement` on Chikou, `0` on the rest) for a chart to apply — the
185
+ data-side half of the charts ask **C2**. Measured against the common slip
186
+ (taking the ranges over the close): up to 0.2281 / 0.2383 / 0.2226 /
187
+ 0.2328 on the oracle's deliberately narrow bars.
188
+ - **`zigZag({ deviation = 5, high, low, prefix = 'zz' })`** — the price path
189
+ reduced to its swings: `zzPivot` (the pivot price on its own bar),
190
+ `zzDirection` (`+1` rising / `−1` falling leg) and `zzLine` (the straight
191
+ line between consecutive pivots). A leg turns when price retraces
192
+ `deviation` **percent** from the leg's running extreme — measured against
193
+ the peak on a fall, the trough on a rise. **Every column repaints**
194
+ (assessment gap **G6**): a pivot is written at the bar its extreme
195
+ occurred but is not known until a later bar confirms it, so none of the
196
+ three may be fed to a backtest unlagged. The **last leg is provisional**
197
+ and therefore has no pivot and no line — only a direction. A gap resets
198
+ the machine _and_ discards the leg in force, and no line is drawn across
199
+ it. The close-based fork needs no option (`{ high: 'close', low: 'close' }`)
200
+ and ships as its own oracle case; the absolute-deviation fork is measured
201
+ at a whole extra pivot on the fixture.
202
+
203
+ ### Changed
204
+
205
+ - `@pond-ts/financial`: **`bollinger` draws the degenerate band on a flat
206
+ window** — `upper = lower = middle` where `σ = 0`, instead of blanking
207
+ both bands around an unbroken middle line. `undefined` now means warm-up
208
+ only, as it does for `keltner`'s zero-range channel. A consumer's
209
+ "outside the band" test is `bbUpper > bbLower`, not a hole in the data.
210
+ `bollingerBandwidth` is still `0` there (its numerator is forced to zero)
211
+ and `bollingerPercentB` still `undefined` (a genuine 0/0), and `bbWidth`
212
+ is now recoverable from the `bollinger` columns on every bar the bands
213
+ are set. Asked for by a consumer whose band over a stale stretch broke
214
+ into segments ([PND-BBFLAT]).
215
+ - `@pond-ts/financial`: **`TradingCalendar.tagSessions` is ~4.7× faster** and
216
+ its output is unchanged on every row. It was materializing `series.toArray()`
217
+ and reading `event.begin()` — one `Event` plus one data object per row, the
218
+ cost PR #536 removed from the study kernel — where it now reads
219
+ `keyColumn().begin` columnar through the shared `sessionIdValues` walk the
220
+ session-anchored studies use. Measured at 1M bars: **120.90 ms → 25.69 ms**.
221
+ The session column it appends is now a `Float64Array` rather than an
222
+ `Array<number | undefined>`; `withColumn` maps `NaN` to missing, so readers
223
+ still see `number | undefined` and the declared `TaggedSchema` is unchanged.
224
+ - `@pond-ts/financial`: `anchoredVwap` now runs the shared
225
+ `anchoredVwapValues` kernel rather than composing two `cumulativeValues`
226
+ passes over two blanked arrays. Bit-identical output; the kernel arithmetic
227
+ measures **23.51 ms → 16.62 ms** at 1M rows.
228
+
229
+ ## [0.66.0] — 2026-09-07
230
+
231
+ ### Added
232
+
233
+ ### Changed
234
+
235
+ - `@pond-ts/financial`: **the volume and miscellaneous leftovers** (corpus
236
+ §6.6 / §6.4 / §6.1) — six studies in the uniform shape (bar columns plus an
237
+ `output` or `prefix`, bar-count periods, a length-preserving per-column
238
+ warm-up, a fluent method), each with oracle cases. Two internal kernel
239
+ helpers ride with them: `trueRangeBoundsValues` (the true high / true low
240
+ the true range is the width of) and `moneyFlowVolumeValues` (the `clv ·
241
+ volume` term the A/D line already accumulated, named so Twiggs can share
242
+ it).
243
+ - **`twiggsMoneyFlow({ period = 21, high, low, close, volume, output =
244
+ 'tmf' })`** — Colin Twiggs' correction to Chaikin Money Flow: the close's
245
+ location in the bar's **true** range (so a gap bar is scored against the
246
+ ground it covered), Wilder-smoothed against a matching smoothing of
247
+ volume rather than summed over a flat window. Bounded −1…1. **F-AMBIG**
248
+ on the smoothing: Wilder's exponential form ships (Incredible Charts /
249
+ Twiggs' own published algorithm) and the window-sum fork is measured at
250
+ **0.0706** away at `period 21`, where TMF itself spans −0.0279…0.1493;
251
+ `chaikinMoneyFlow(21)` sits **0.1494** away — wider than the whole
252
+ reading. Warm-up is `period` (bar 0 has no true range), and an interior
253
+ gap in any input **ends** the reading, where CMF recovers a window later.
254
+ - **`tradeVolumeIndex({ minTick, column = 'close', volume, output = 'tvi'
255
+ })`** — tick-direction volume accumulation: `+1` above the dead band, `−1`
256
+ below it, and **the previous direction** on an undecided bar, which is
257
+ what the exchange tick rule does and what distinguishes it from `obv`
258
+ (which adds nothing for an unchanged close). **`minTick` is required**
259
+ and has no default — a fact about the instrument, like `swingIndex`'s
260
+ `limit`; `0`, negative and non-finite are rejected. The level starts at
261
+ `0`, no first direction is invented (the up-seeded vendor fork is pinned
262
+ by a unit test — the oracle input's first bar carries its largest move,
263
+ so the fork is invisible there, and the generator asserts that), and an
264
+ interior gap **ends** the index the way `obv` does rather than re-seeding
265
+ the way `negativeVolumeIndex` does.
266
+ - **`shinoharaIntensityRatio({ period = 26, open, high, low, close, prefix =
267
+ 'sir' })`** → `sirStrong`, `sirWeak` — Shinohara's A and B ratios,
268
+ `100·Σup/Σdown` over the window: A measured against each bar's **own
269
+ open**, B against the **previous close**. **F-AMBIG** on the A/B naming:
270
+ the arithmetic is the standard pair and the `strong` / `weak` labels come
271
+ from the corpus' own list; the alternative convention charts them the
272
+ other way round, and the two lines sit **23,312.47** apart on the oracle
273
+ input, so the labels carry information. Per-column warm-up (25 and 26 at
274
+ the default). Neither ratio is bounded, and **B inverts on a gappy tape**
275
+ — `prevClose − low` is negative on a bar that gapped up, which is the
276
+ definition, not a defect (documented, with the oracle input's own
277
+ −22,761…7,620 range as the worked case).
278
+ - **`elderImpulse({ emaPeriod = 13, fastPeriod = 12, slowPeriod = 26,
279
+ signalPeriod = 9, column = 'close', output = 'impulse' })`** — Elder's
280
+ Impulse System: `+1` when the 13-bar EMA **and** the MACD histogram both
281
+ rise, `−1` when both fall, `0` otherwise. The column is **numeric**, not
282
+ the categorical colour the corpus describes — `TimeSeries.withColumn` has
283
+ no string door, so a study cannot append one; `+1 / 0 / −1` is signed, so
284
+ a chart maps it to Elder's green/blue/red in one expression. It calls
285
+ `macd()` under a scratch prefix rather than re-deriving the histogram, so
286
+ it is by construction the same MACD a caller charts beside it. Ties are
287
+ `0` (strict comparison on both sides), so it carries no state and needs
288
+ no gap rule of its own; warm-up 34 at the defaults.
289
+ - **`movingAverageCross({ fastPeriod = 10, slowPeriod = 30, maType = 'sma',
290
+ column = 'close', output = 'maCross' })`** — a **signal** column: `+1` on
291
+ the bar the fast average crosses above the slow one, `−1` below, `0`
292
+ otherwise. The averages are not emitted (that is `movingAverage`'s job);
293
+ what this adds is the event, which needs memory of which side the pair
294
+ was last on — a K6 `foldRows` machine. **The tie rule is the design**: an
295
+ exact tie is not a cross, a touch-and-retreat is not a cross (the machine
296
+ carries the last **non-zero** sign), and a crossing _through_ a tie fires
297
+ on the far-side bar. `maType`, not `type`, because the column is a signal
298
+ rather than an average. The first bar on which both averages exist
299
+ reports `undefined`, not `0` — it is the seed. **What a gap costs depends
300
+ entirely on `maType`**, and the docstring carries the measured table: at
301
+ the default `sma` a hole costs _nothing_ (the K2 column door counts rows),
302
+ at `ema` two bars, at `wma`/`hull` a window, and at `smma`/`kama` the rest
303
+ of the series.
304
+ - **`anchoredVwap({ anchor, high, low, close, volume, output = 'avwap' })`**
305
+ — the cumulative `Σ typicalPrice·volume / Σ volume` from a chosen bar
306
+ onwards: the VWAP of the execution desk, and the half of VWAP that
307
+ `vwap()`'s docstring named and left open. **`anchor` is required** and is
308
+ a `Date` or epoch **milliseconds**, not a row index — the line starts at
309
+ the first bar at or after it, so an anchor between bars snaps forward, one
310
+ before the series covers all of it, and one after the last bar leaves the
311
+ column empty (not an error). The **session-reset** form still waits on
312
+ [PND-TCAL]; what makes this one shippable today is that its anchor is a
313
+ user parameter, so no calendar is consulted. An interior gap **ends** the
314
+ line (`obv`'s cumulative rule), and the volume sum is blanked wherever
315
+ the price sum is so the two consume the same bars.
316
+
317
+ - `@pond-ts/financial`: **the momentum and trend leftovers** (corpus §6.3 /
318
+ §6.4 / §6.1) — ten studies in the uniform shape (a `column` or the bar
319
+ columns plus an `output` or `prefix`, bar-count periods, a length-preserving
320
+ per-column warm-up, a fluent method), each with oracle cases. Two internal
321
+ kernels ride with them: `swingIndexValues` (shared by the swing pair) and
322
+ `randomWalkValues` (the multi-horizon sweep the corpus flags as **G2**).
323
+ - **`stochasticMomentumIndex({ period = 13, longPeriod = 25, shortPeriod =
324
+ 2, signalPeriod = 3, high, low, close, prefix = 'smi' })`** → `smi`,
325
+ `smiSignal` — William Blau's SMI: the close against the **midpoint** of
326
+ the `period`-bar range, with the distance and the half-range each
327
+ double-EMA smoothed before the division, ×100. Bounded −100…100 and
328
+ signed, where the classic stochastic reads 50 at the midpoint. Option
329
+ names are `trueStrengthIndex`'s (Blau's other double-smoothed
330
+ study, same author, same `r`/`s`). Blau's `(13, 25, 2)` ship; the short
331
+ `(5, 3, 3)` fork sits **108.99** away and the unsmoothed reading
332
+ **126.95**, on a line spanning −48.19…74.33 (measured). A flat range for
333
+ the whole smoothed history reads `undefined` — the guard is live via a
334
+ redirected `close`.
335
+ - **`fisherTransform({ period = 10, high, low, prefix = 'fisher' })`** →
336
+ `fisher`, `fisherSignal` — John Ehlers' Fisher Transform (TASC Nov 2002),
337
+ a `foldRows` state machine. The range is over the **median price's** own
338
+ extremes, not over the bars' highs and lows: that port sits **5.95** away
339
+ on a line spanning −4.69…7.60 (measured). `0.33/0.67`, the `±0.99` clamp
340
+ test with its `±0.999` replacement, and the `0.5/0.5` second smoothing are
341
+ Ehlers' constants, not options. The signal is the line **delayed one
342
+ bar**. A gap — or a flat window — resets the machine ([PND-SFOLD]).
343
+ - **`schaffTrendCycle({ fastPeriod = 23, slowPeriod = 50, cyclePeriod = 10,
344
+ column, output = 'stc' })`** — Doug Schaff's STC: a stochastic of the MACD,
345
+ `0.5`-smoothed, then a stochastic of that, `0.5`-smoothed again. Bounded
346
+ 0…100; the raw double stochastic is **98.30** away (measured). Both
347
+ recursions are `foldRows` steps, so a gap resets them rather than
348
+ repeating the previous reading as the common port does — and that is
349
+ visible: a sustained trend pins the first stochastic, leaving the second
350
+ window flat and the line `undefined`, which on the package's own oracle
351
+ input delays the start from bar 67 to bar **74**.
352
+ - **`prettyGoodOscillator({ period = 14, column, high, low, close, output =
353
+ 'pgo' })`** — Mark Johnson's PGO, `(close − SMA)/EMA(TR)`, in units of
354
+ average daily ranges. **F-AMBIG**: Johnson's span-EMA denominator ships
355
+ and the Wilder-`ATR` port is named and measured (**0.211** apart at
356
+ period 14 on a line spanning −3.71…4.23). `column` defaults to whatever
357
+ `close` resolves to, as `atrBands` does.
358
+ - **`swingIndex({ limit, open, high, low, close, output = 'si' })`** and
359
+ **`accumulativeSwingIndex({ …, output = 'asi' })`** — Wilder's 1978 swing
360
+ index and its running total. **`limit` (Wilder's `T`, the instrument's
361
+ limit move) is required** and has no default: it is a fact about the
362
+ instrument, every possible default silently rescales the reading, and the
363
+ ±100 bound the study is defined by depends on it. A `limit` of 0 (or
364
+ negative, or non-finite) is rejected. `R = 0` — a tape frozen for two
365
+ bars — reads `undefined`, which for the ASI ends the level, the same rule
366
+ OBV follows.
367
+ - **`randomWalkIndex({ period = 14, high, low, close, prefix = 'rwi' })`** →
368
+ `rwiHigh`, `rwiLow` — E. Michael Poulos' RWI: the **maximum over horizons
369
+ `2 … period`** of `(high − low[−n]) / (meanTR(n)·√n)`. The denominator is
370
+ the `n`-bar **mean** true range, not Wilder's ATR (that variant is
371
+ **0.190** away at period 14; the single-horizon form **2.374**, measured).
372
+ Deliberately **O(N·period)** — the G2 shape no window kernel expresses —
373
+ with O(N) memory; 328 ms at `period 14` and 1194 ms at `period 50` on 1M
374
+ bars. Both columns go **negative** when every horizon fell.
375
+ - **`ravi({ shortPeriod = 7, longPeriod = 65, column, output = 'ravi' })`** —
376
+ Tushar Chande's Range Action Verification Index,
377
+ `100·|SMA(short) − SMA(long)|/SMA(long)`. The **absolute value** is the
378
+ study: it answers "is this trending" (above 3%), not "which way" — the
379
+ signed form is **26.98** away (measured). Scale-invariant, not
380
+ shift-invariant.
381
+ - **`trendIntensityIndex({ period = 30, maPeriod = 60, column, output =
382
+ 'tii' })`** — M. H. Pee's TII, `100·Σpos/(Σpos + Σneg)` over the last
383
+ `period` deviations from a `maPeriod` SMA. **F-AMBIG**: Pee's **sums**
384
+ ship, weighted by how far price strayed; the common **count** form is
385
+ **28.85** away (measured) and would deserve its own name. Bounded 0…100,
386
+ warm-up `maPeriod + period − 2` = **88** bars at the defaults.
387
+ - **`specialK({ column, output = 'specialK' })`** — Martin Pring's Special
388
+ K: the KST construction extended to **twelve** weighted smoothed rates of
389
+ change across three groups. The thirty-six constants are the study, so
390
+ there are no period options (the `kst` decision). **Warm-up 724 bars** —
391
+ a series shorter than 725 comes back entirely `undefined`, with the row
392
+ count preserved.
393
+ - `@pond-ts/financial`: **the prime studies and Bill Williams' Market
394
+ Facilitation Index** (corpus §6.2 / §6.3 / §6.6) — three per-bar studies with
395
+ no window, no warm-up and no parameters beyond their column names.
396
+ - **`primeNumberBands({ high?, low?, prefix = 'pnb' })`** → `pnbUpper`,
397
+ `pnbLower` — the smallest prime at or above the bar's high and the largest
398
+ at or below its low, so the bands **contain** the bar. A step function of
399
+ the price _level_: it holds flat inside a prime gap and jumps across one,
400
+ and two instruments at the same price have identical bands.
401
+ - **`primeNumberOscillator({ column = 'close', output = 'pno' })`** —
402
+ `price − nearestPrime(price)`, positive when the price sits above the prime
403
+ nearest it and exactly `0` when it **is** an integer prime. Both open
404
+ conventions are pinned: the sign is `price − prime`, and an **equidistant
405
+ tie goes to the lower prime** (so `pno(6) = +1`).
406
+ - Both: **a price below 2 is outside the domain** and reads `undefined` — two
407
+ is the smallest prime, so there is nothing to bracket with. **Neither is
408
+ scale- nor shift-equivariant**, and the property tests assert that
409
+ _absence_ rather than skipping the check. **Cost grows with the price
410
+ magnitude**, the only operators in the package that do: measured at 1M
411
+ bars, ~78 ms and ~55 ms at ordinary equity prices and **~6.5 s each at
412
+ ~1e7**. There is no sieve; the kernel documents why and what would change
413
+ it.
414
+ - **`marketFacilitationIndex({ high?, low?, volume?, output = 'bwmfi' })`** —
415
+ Bill Williams' `(high − low) / volume`, the raw ratio with **no `scale`
416
+ option** (no vendor constant is standard, unlike `easeOfMovement`'s
417
+ published `100_000_000`). The default output is **`bwmfi`, not `mfi`**:
418
+ `moneyFlowIndex` — a different indicator published under the same
419
+ abbreviation — already owns that name, and both must be able to sit on one
420
+ series. A zero-volume bar reads `undefined`; a flat bar reads `0`.
421
+ - `@pond-ts/financial`: **the bands and channels tail** (corpus §6.2) — four
422
+ studies in the uniform shape, each with oracle cases and a fluent method.
423
+ - **`starcBands({ period = 20, atrPeriod = 15, multiplier = 2, maType =
424
+ 'sma', high?, low?, close?, prefix = 'starc' })`** → `starcMiddle`,
425
+ `starcUpper`, `starcLower` — Manning Stoller's channel: a moving average
426
+ of the **close** with bands a multiple of the ATR away. The close-centred
427
+ sibling of `keltner` (typical-price centre) and of `atrBands` (bands
428
+ around an existing column, no centre). It **is** `movingAverage` followed
429
+ by `atrBands` around that average, pinned bit-for-bit by a test; it ships
430
+ for the name and the centre column. Per-column warm-up: centre at the
431
+ MA's own bar, bands at `max(centre, ATR)`.
432
+ - **`highLowBands({ period = 10, percent = 1, maType = 'trima', high?, low?,
433
+ prefix = 'hlb' })`** → `hlbMiddle`, `hlbUpper`, `hlbLower` — a moving
434
+ average of the **median price** with bands `± percent`. It **is**
435
+ `envelope` over a `medianPrice` column, pinned bit-for-bit.
436
+ - **`bollingerBandwidth({ period = 20, stdDev = 2, column?, output =
437
+ 'bbWidth' })`** — `100·(upper − lower)/middle`, the **×100** StockCharts /
438
+ ChartIQ form rather than Bollinger's bare ratio. A **flat window reads
439
+ `0`**, not `undefined`: the numerator is `2·stdDev·σ`, forced to zero,
440
+ over a non-zero centre — so on a flat stretch it is deliberately **not**
441
+ recoverable from `bollinger`'s (missing) band columns, while everywhere
442
+ else it is bit-for-bit.
443
+ - **`bollingerPercentB({ period = 20, stdDev = 2, column?, output =
444
+ 'percentB' })`** — `(price − lower)/(upper − lower)`, the **decimal** form
445
+ (`0` = lower band, `1` = upper), unbounded so an out-of-band price still
446
+ reads. A flat window is a genuine `0/0` and reads `undefined` — the
447
+ opposite answer from BandWidth's on the same window, and the clearest
448
+ illustration of the "is the numerator _forced_ to zero?" test.
449
+ - `@pond-ts/financial`: **the K3 price transforms and Balance of Power**
450
+ (corpus §6.8) — five studies in the uniform shape (each bar-column named by
451
+ its own option defaulting to its `DEFAULT_OHLCV` name, an `output`, a
452
+ fluent method), all five **exact** against TA-Lib bar-for-bar.
453
+ - **`typicalPrice({ high?, low?, close?, output = 'typicalPrice' })`**
454
+ (`(h+l+c)/3`, TA-Lib `TYPPRICE`),
455
+ **`medianPrice({ high?, low?, output = 'medianPrice' })`** (`(h+l)/2`,
456
+ `MEDPRICE`),
457
+ **`weightedClose({ high?, low?, close?, output = 'weightedClose' })`**
458
+ (`(h+l+2c)/4`, `WCLPRICE`) and
459
+ **`averagePrice({ open?, high?, low?, close?, output = 'averagePrice' })`**
460
+ (`(o+h+l+c)/4`, `AVGPRICE`). These are the per-bar summaries `vwap`, `cci`,
461
+ `mfi`, `keltner` and the Awesome Oscillator derive privately, now available
462
+ as **columns** so a caller can chart one or run another study over it
463
+ (`sma({ column: 'typicalPrice' })`). **No warm-up at all** — a bar's own
464
+ prices are all any of them reads, so bar 0 carries a value.
465
+ - **`balanceOfPower({ period?, maType?, open?, high?, low?, close?, output =
466
+ 'bop' })`** — Igor Livshin's `(close − open) / (high − low)`, the body over
467
+ the range, bounded −1…1. **Raw by default** (exact against TA-Lib `BOP`);
468
+ the optional `period` is ChartIQ's smoothed form over the shared `MaType`
469
+ menu. **F-AMBIG** — both conventions are published, and the one an oracle
470
+ can pin ships as the default. Passing `maType` without a `period` **throws**
471
+ rather than silently doing nothing. A **flat bar reads `0`, not
472
+ `undefined`**: a bar with no range traded at one price, so the numerator is
473
+ forced to zero (the `clvValues` rule from the volume group), which is
474
+ TA-Lib's answer too.
475
+ - `@pond-ts/financial`: **the moving-average stacks and the smoothed-momentum
476
+ tail** (corpus §6.1 / §6.3) — seven studies in the uniform shape (a `column`
477
+ plus an `output` or `prefix`, bar-count periods, a length-preserving
478
+ per-column warm-up, a fluent method), each with oracle cases.
479
+ - **`guppy({ column = 'close', type = 'ema', prefix = 'gmma' })`** — Daryl
480
+ Guppy's Multiple Moving Average: the **fixed twelve** averages, short
481
+ `3, 5, 8, 10, 12, 15` as `gmmaS3 … gmmaS15` and long
482
+ `30, 35, 40, 45, 50, 60` as `gmmaL30 … gmmaL60`. There is deliberately no
483
+ "which periods" option — the twelve are the study — but `type` takes the
484
+ whole shared `MaType` menu. The periods are exported as
485
+ `GUPPY_SHORT_PERIODS` / `GUPPY_LONG_PERIODS` so a chart can label the
486
+ ribbon. Each column is checked against `talib.MA` **at its own period**
487
+ (exact for `sma`; for `ema` the formula on TA-Lib's SMA seed plus a
488
+ geometric-decay check on pond's first-sample seed). Warm-up is
489
+ **per column** — `gmmaS3` at bar 2, `gmmaL60` at bar 59, which is 57 real
490
+ values of the fast ribbon a shared warm-up would have discarded.
491
+ - **`rainbow({ column = 'close', period = 2, type = 'sma', prefix =
492
+ 'rainbow' })`** — Mel Widner's Rainbow Moving Average (TASC, July 1997):
493
+ ten **recursive** averages, each smoothing the previous one, as
494
+ `rainbow1 … rainbow10`. Stage `k` warms up at `k · (period − 1)`, so ten
495
+ 2-bar averages are a ten-bar look-back. Deliberately **not** the
496
+ ten-increasing-lengths variant also published under the name: a recursive
497
+ 2-bar mean is a binomial filter, and stage 10 sits **0.83** from the
498
+ 11-bar SMA covering the same support (measured; the fixture's whole close
499
+ range is 19.4).
500
+ - **`rainbowOscillator({ column = 'close', period = 2, lookback = 10,
501
+ prefix = 'rbo' })`** → `rbo`, `rboUpper`, `rboLower` — ChartIQ's:
502
+ `100·(price − mean of the ten)/(HH − LL)` over `lookback` bars of
503
+ `column`, with the stack's own `max − min` as bands mirrored about zero.
504
+ **F-AMBIG**, so the source is named and the alternatives measured: the
505
+ divide-by-price form is **67.27** away and a first-average numerator
506
+ **49.16**, against a reading spanning −68.6…63.8. A flat `lookback`
507
+ window reads `undefined` — nothing forces the numerators to zero, so it
508
+ is a real number over zero rather than a `0/0`.
509
+ - **`kst({ column = 'close', signalPeriod = 9, prefix = 'kst' })`** →
510
+ `kst`, `kstSignal` — Martin Pring's Know Sure Thing on his intermediate
511
+ **daily** set: four percent rates of change (look-backs 10 / 15 / 20 /
512
+ 30), each smoothed by a simple average (10 / 10 / 10 / 15), weighted
513
+ 1 / 2 / 3 / 4 and summed, with a simple-average signal. The twelve
514
+ constants are deliberately **not** options — Pring published several KSTs
515
+ (short daily, weekly, monthly) and they are different indicators, not one
516
+ with parameters — so only `signalPeriod` is exposed. The line warms up at
517
+ bar 44, the signal `signalPeriod − 1` later. Scale-invariant, not
518
+ shift-invariant.
519
+ - **`priceMomentumOscillator({ column = 'close', prefix = 'pmo' })`** →
520
+ `pmo`, `pmoSignal` — DecisionPoint's PMO. Its two smoothing stages use
521
+ DecisionPoint's **custom** multiplier `α = 2/n`, which is **not** the
522
+ span EMA's `2/(n+1)` and is the only non-span exponential in the package:
523
+ measured, building both stages on the span EMA instead puts the line
524
+ **0.106** away on a reading whose scale is 3.906 (2.7%). The **signal**
525
+ is a plain span `EMA(10)` — DecisionPoint's own asymmetry, and a
526
+ custom-smoothed signal would sit 0.088 away. No period options; the line
527
+ warms up at bar 54 and the signal at 63.
528
+ - **`stochasticRsi({ column = 'close', rsiPeriod = 14, stochPeriod = 14,
529
+ kPeriod = 3, dPeriod = 3, prefix = 'stochRsi' })`** → `stochRsiK`,
530
+ `stochRsiD` — Chande & Kroll's Stochastic RSI, composed on the shipped
531
+ `rsi` rather than a private copy. **`stochRsiK` equals TA-Lib
532
+ `STOCHRSI`'s `fastd`** (not its `fastk`) bar-for-bar with identical
533
+ masks, measured to 9.9e-14; `stochRsiD` has no TA-Lib counterpart, since
534
+ `STOCHRSI` stops at `fastd`. Crossing the two columns is a 45-point error
535
+ on the oracle input, and the generator asserts both the match and the
536
+ mismatch. The option names are TradingView's, so **`stochPeriod` here is
537
+ `stochastic`'s `kPeriod`** and **`kPeriod` here is its `slowing`** — a
538
+ table in the docstring maps all three vocabularies.
539
+ - **`trueStrengthIndex({ column = 'close', longPeriod = 25, shortPeriod =
540
+ 13, signalPeriod = 7, prefix = 'tsi' })`** → `tsi`, `tsiSignal` — William
541
+ Blau's TSI: the bar-over-bar change double-smoothed, over its own
542
+ magnitude double-smoothed the same way, ×100. Bounded −100…100 by
543
+ construction. The smoothing **order is the definition** — `longPeriod`
544
+ first, `shortPeriod` to its output — and the swap sits **15.93** away on
545
+ a line spanning −28.7…80.7 (measured; the generator asserts it). A
546
+ perfectly flat column gives a zero denominator with the numerator forced
547
+ to zero, a genuine `0/0` → `undefined`. Scale- **and** shift-invariant,
548
+ unlike `kst` and `priceMomentumOscillator`. Options are named
549
+ `longPeriod` / `shortPeriod`, not `long` / `short`, which read as
550
+ position vocabulary in a financial package.
551
+ - **`movingAverageDeviation({ column = 'close', period = 20, maType =
552
+ 'sma', output = 'maDev' })`** — `price − MA`, in **price units**, over
553
+ the shared `MaType` menu. The corpus lists this study as "points **or**
554
+ percent"; the percent half is **already shipped** as `disparityIndex`,
555
+ and the two agree **bit for bit** (`100·maDev/MA === disparity`,
556
+ measured 0.0, asserted in the oracle), so only the points form ships and
557
+ there is no `mode` flag — two indicators behind an option is the
558
+ `keltner` precedent this package avoids. It is the one study in the pair
559
+ that is **shift-invariant** (the constant cancels between the price and
560
+ its own average) and the one with no division and so no
561
+ zero-denominator case.
562
+ - `@pond-ts/financial`: **the K6 stateful-fold kernel and the state-machine
563
+ studies** (corpus §6.4 / §6.6, gap **G2**) — one new public kernel and six
564
+ studies in the uniform shape (`column` / `output` or a `prefix`, bar-count
565
+ periods, a length-preserving warm-up, a fluent method), each with oracle
566
+ cases.
567
+ - **`foldRows(inputs, outputCount, state, step)`** (kernel **K6**,
568
+ [PND-SFOLD]) — a per-bar fold with carried state over several row-aligned
569
+ `Float64Array` columns, the one shape the rolling kernels and core's
570
+ single-column `scan` cannot express. One O(N·k) pass, outputs allocated
571
+ and **NaN-filled** by the kernel, the state object the study's own and
572
+ mutated in place — no per-bar allocation. Exported with its `FoldStep`
573
+ type. Measured at 1M bars: **11.3 ms** for a two-column fold over a no-op
574
+ step, against `ema()`'s 6.3 ms and a 5.2 ms floor for the same scan with
575
+ no callback.
576
+ - **A missing cell RESETS the machine.** The step is offered only complete
577
+ rows, with `run` = how many consecutive complete rows end here; an
578
+ incomplete row leaves the outputs `undefined` and restarts `run` at 1.
579
+ The alternative — holding the state across the gap — was rejected: a
580
+ Parabolic SAR that did not see a bar cannot know whether it flipped, and
581
+ a recursion carried on the wrong side never recovers. `run` doubles as
582
+ the seeded flag, which is why there is no separate `seed` hook.
583
+ - **`parabolicSar({ step = 0.02, maxStep = 0.2, high?, low?, prefix = 'psar' })`**
584
+ → `psar` + `psarTrend` — Wilder's stop-and-reverse, **bar-for-bar
585
+ identical to TA-Lib `SAR`** (measured 0.0 maximum absolute difference at
586
+ `(0.02, 0.2)`, `(0.05, 0.5)` and `(0.01, 0.1)`, masks identical, first
587
+ value at bar 1). The three prose-ambiguous details — the `−DM` seed side,
588
+ the first bar's "yesterday is today" clamp, and the clamp against the last
589
+ two bars on a reversal — are pinned to TA-Lib's reading and **probed**
590
+ against it in the oracle generator.
591
+ - **`superTrend({ period = 10, multiplier = 3, high?, low?, close?, prefix = 'st' })`**
592
+ → `st` + `stTrend` — Olivier Seban's ratcheting ATR band, as TradingView's
593
+ `ta.supertrend` implements it, on the same Wilder `atrValues` `atr` and
594
+ `keltner` read. The two bands are **not** emitted: `st` already is
595
+ whichever one is live.
596
+ - **`atrTrailingStop({ period = 14, multiplier = 3, high?, low?, close?, prefix = 'ats' })`**
597
+ → `ats` + `atsTrend` — the close-anchored ratcheting stop (Vervoort's).
598
+ The **Chandelier** anchor (rolling extreme rather than the close) is a
599
+ different study, reachable as `donchian` + `atr`, and deliberately not a
600
+ knob here.
601
+ - **`negativeVolumeIndex` / `positiveVolumeIndex`
602
+ (`{ column = 'close', volume?, output = 'nvi' | 'pvi', start = 1000 }`)** —
603
+ Fosback's conditional cumulative indices. No period and no warm-up. A
604
+ **flat volume holds on both**, so the two do not partition the bars; a
605
+ zero previous close ends the index rather than inventing a level.
606
+ - **`klinger({ fastPeriod = 34, slowPeriod = 55, signalPeriod = 13, high?, low?, close?, volume?, prefix = 'kvo' })`**
607
+ → `kvo` + `kvoSignal` — Klinger's **original** volume force
608
+ (`volume × |2 × (dm/cm − 1)| × trend × 100`, with `cm` accumulating over
609
+ the trend leg) through an EMA pair plus a signal EMA. **F-AMBIG**:
610
+ TradingView's `ta.kvo`, which drops the `dm/cm` factor entirely, is a
611
+ different indicator sharing the name and is documented rather than offered
612
+ as an option.
613
+ - **The three stop machines append `${prefix}` and `${prefix}Trend`**, the
614
+ value under the bare prefix rather than `${prefix}Line`, with `+1` meaning
615
+ the line sits **below** price. The side is a separate column because it is
616
+ not recoverable from the value — all three clamp, so the line can print
617
+ exactly on an extreme or on the close.
618
+ - `@pond-ts/financial`: **the rolling linear-regression family** (corpus §6.7,
619
+ plus the two §6.3 studies that hang off it) — one new public kernel and four
620
+ studies in the uniform shape (a `column` / `output` or `prefix`, bar-count
621
+ periods, a length-preserving warm-up, a fluent method), each with oracle
622
+ cases at two periods.
623
+ - **`linearRegressionValues(values, period)`** (kernel **K7**) — the rolling
624
+ ordinary-least-squares fit of a raw `Float64Array` against the bar index,
625
+ returning `{ slope, intercept, r2 }` in **one O(N) pass that is flat in
626
+ `period`** (measured at 1M bars: 29 ms at `period 14`, 29 ms at
627
+ `period 200`). `x` is deterministic, so `Σx` and `Σx²` are closed forms and
628
+ only `Σy`, `Σxy` and `Σy²` roll. Exported alongside its `RollingRegression`
629
+ result type.
630
+ Near-flat windows (changing by ulps, or a plateau the anchor has gone
631
+ stale across) are recomputed two-pass on a fresh local anchor when the
632
+ rolling spread falls below `1e-3` of the gross magnitude that has passed
633
+ through its sums since the last rebuild — the sign is not the tell — and
634
+ `r2` is pinned to 1; every changing window is tested against an exact
635
+ BigInt-rational reference (review, 2026-09-07).
636
+ - **`linearRegression({ period = 14, column = 'close', prefix = 'linreg' })`**
637
+ → `linregValue`, `linregSlope`, `linregIntercept`, `linregAngle`,
638
+ `linregR2` — five readings of **one** fit, all warming up together at
639
+ `period − 1`. Four are **exact against TA-Lib** (`LINEARREG`,
640
+ `LINEARREG_SLOPE`, `LINEARREG_INTERCEPT`, `LINEARREG_ANGLE`; measured
641
+ ≤ 1.3e-12 at `period 14`, masks identical); `linregR2` has no TA-Lib
642
+ equivalent and is a pandas replication.
643
+ - **`linregIntercept` is the fit at the window's FIRST bar** (`x = 0`),
644
+ TA-Lib's convention — `linregValue` is the fit at its last. On the
645
+ oracle input at `period 14` the two differ by up to **13.68 points** on
646
+ a series whose entire range is 19.4, so reading one for the other is a
647
+ real error rather than a rounding one.
648
+ - **`linregAngle` is scale-DEPENDENT**, and deliberately so: TA-Lib's
649
+ `LINEARREG_ANGLE` is `atan(slope)` in degrees with no normalisation, so
650
+ the same instrument quoted in cents reads a different angle. Stated on
651
+ the study and pinned by a property test that asserts scaling **moves**
652
+ it.
653
+ - **`timeSeriesForecast({ period = 14, column, output = 'tsf' })`** — the
654
+ same fit projected one bar past the window (`intercept + slope·period`),
655
+ **exact against TA-Lib `TSF`** (≤ 2.1e-13). Also known as the "time series
656
+ moving average" and deliberately **not** added to the K2 `MaType` menu:
657
+ every type there is the identity at `period 1`, and a one-bar window has
658
+ no slope.
659
+ - **`chandeForecastOscillator({ period = 14, column, output = 'cfo' })`** —
660
+ `100 · (price − TSF) / price` (Tushar Chande). No TA-Lib function; the
661
+ oracle is a pandas replication on the TA-Lib-checked `TSF`, separated from
662
+ the version that subtracts the in-window endpoint (0.89 at `period 14`).
663
+ Scale-invariant, **not** shift-invariant.
664
+ - **`centerOfGravity({ period = 10, column, output = 'cog' })`** — Ehlers'
665
+ position-weighted balance point (_Stocks & Commodities_, May 2002),
666
+ `−Σ(k+1)·price[i−k] / Σprice[i−k]` with the **newest** bar carrying weight
667
+ 1. Negative and bounded `[−period, −1]` on positive prices (a
668
+ zero-crossing source column can read outside it), and a flat window balances at
669
+ `−(period+1)/2`. This is **TradingView's uncentred `ta.cog`**; Ehlers' own
670
+ EasyLanguage adds `(period+1)/2` to re-centre on zero, a constant offset
671
+ the oracle pins. It needs no new kernel: the descending weights are the
672
+ ascending ones subtracted from a constant, so the study is exactly
673
+ `(period + 1)·(WMA/(2·SMA) − 1)` over the K2 engine's `wma` and
674
+ `rollingMeanValues` — an identity pinned by a test against the naive
675
+ `O(N·period)` definition.
676
+ - **All four run the strict window**: a bar is emitted only when all
677
+ `period` cells are finite, because `x` names a _position_ and dropping a
678
+ cell would fit the line against the wrong abscissa (the `wma` rule). A
679
+ leading gap shifts the start; an interior gap blanks `period` bars and
680
+ then recovers.
681
+ - **A flat window: `slope` exactly `0`, `linregR2` `undefined`.** The
682
+ slope's numerator is forced to zero by the same condition that empties
683
+ the window, so `0` is a real reading; `R²` is a genuine `0/0` and is
684
+ missing. Both are decided by an O(1) count of the changes inside the
685
+ window rather than computed — without it the accumulator residue reads
686
+ `slope = −2.1e-14` and **`R² = −13.5`**, outside the statistic's own
687
+ range (measured).
688
+ - The regression periods must be **at least 2** (`n²(n²−1)/12` is `0` at
689
+ `n = 1`); `centerOfGravity` takes a moment rather than a fit, so it
690
+ accepts `period 1` and reads `−1`.
691
+ - `@pond-ts/financial`: **the two-series / comparison family** (corpus §6.7,
692
+ kernel K8) — `correlation`, `beta`, `priceRelative` and `performanceIndex`,
693
+ plus the public kernel `rollingBivariateValues`. Seven oracle cases; the two
694
+ TA-Lib-backed studies agree with `CORREL` and `BETA` bar-for-bar.
695
+ - **The comparison series is a `benchmark` COLUMN on the same series, never a
696
+ second `TimeSeries`.** The consumer aligns and joins first
697
+ (`align` + `TimeSeries.joinMany`), and the study reads two columns of one
698
+ row — exactly as `atr` reads `high`/`low`/`close`. A study that took a
699
+ second series would have to invent an alignment policy (hold?
700
+ interpolate? inner or outer?) that core already expresses, better and once
701
+ for the whole pipeline. A `benchmark` or `column` that is not on the
702
+ series **throws** rather than reading empty.
703
+ - **`correlation({ period = 30, column = 'close', benchmark, output = 'corr' })`**
704
+ → Pearson's `r` over the two **price** columns, `= talib.CORREL`
705
+ bar-for-bar. TA-Lib correlates the raw inputs, not their returns; the
706
+ return correlation is the same study over two `percentChange` columns.
707
+ - **`beta({ period = 5, column = 'close', benchmark, output = 'beta' })`** →
708
+ `cov(returns, benchmarkReturns) / var(benchmarkReturns)` over `period`
709
+ one-bar returns. **Pass prices** — the returns are taken inside, as
710
+ `percentChangeValues(v, 1)`. Equals `talib.BETA(benchmark, close)`
711
+ bar-for-bar: TA-Lib regresses its **second** input on its **first**
712
+ (measured), so the benchmark goes first there; pond's option names remove
713
+ the ambiguity.
714
+ - **`priceRelative({ column = 'close', benchmark, output = 'priceRel' })`** →
715
+ the bare ratio, with **no period and no warm-up**. ChartIQ's _Price
716
+ Relative_ and _Relative Strength (comparative)_ are one implementation;
717
+ neither is Wilder's `rsi`.
718
+ - **`performanceIndex({ period = 20, column = 'close', benchmark, output = 'perf' })`**
719
+ → each side's own `period`-bar growth, divided, so `1` is parity. The
720
+ **ratio** form ships (some vendors publish `× 100` or `− 1`);
721
+ `(perf − 1) × 100` is `percentChange(priceRelative, period)` identically,
722
+ which a test asserts.
723
+ - **`rollingBivariateValues(x, y, period)`** → `{ covariance, varianceX,
724
+ varianceY }`, population (`ddof = 0`), over a **strict** pair window: all
725
+ `period` rows of **both** columns, or nothing. Shifted-frame Welford with
726
+ the aligned rebuild ([PND-SHIFTFRAME] / [PND-PROCKERN]), never
727
+ `Σxy − ΣxΣy/n` — measured at 1e12-scale prices, the textbook form returns a
728
+ negative variance (a non-finite correlation) where this holds 2.4e-15.
729
+ Validates `period` (integer `≥ 2`) at the boundary like every public
730
+ kernel, and rebuilds a window on demand when a changing column's `m2`
731
+ falls below `1e-3` of the gross shifted magnitude that has passed through
732
+ it since the last rebuild, or `cxy² > m2x·m2y` past rounding slack — so
733
+ no near-flat window reports a false missing cell or an out-of-range
734
+ correlation; tested against an exact BigInt-rational reference (review,
735
+ 2026-09-07).
736
+ - **Two deliberate TA-Lib deltas, both measured**: a flat window is
737
+ `undefined` here and `0.0` in TA-Lib (`CORREL` and `BETA` both substitute
738
+ zero for a zero denominator), and a zero price is a **missing** return here
739
+ where TA-Lib substitutes a return of `0`.
740
+
741
+ - `@pond-ts/financial`: **the Wilder directional group** (corpus §6.4) — three
742
+ studies and two new public kernels, in the uniform shape (redirectable bar
743
+ inputs, bar-count periods, length-preserving warm-up, a fluent method), each
744
+ with oracle cases at two periods.
745
+ - **`directionalMovement({ period = 14, high?, low?, close?, prefix = 'dmi' })`**
746
+ → `dmiPlusDi`, `dmiMinusDi`, `dmiDx`, `dmiAdx`, `dmiAdxr`. Wilder's Directional
747
+ Movement System — `+DI`, `−DI`, `DX`, `ADX` and `ADXR` in **one** study
748
+ with a **per-column warm-up** (bars 14 / 14 / 14 / 27 / 40 at the default),
749
+ the `macd` precedent rather than five studies re-running the same pipeline.
750
+ The `DI` denominator **is** `atr()`'s array, so the two measure range
751
+ identically by construction.
752
+ - **Seeding is Wilder's, not TA-Lib's**, and this is the one delta.
753
+ Wilder's worksheet seeds `+DM`/`−DM`/`TR` on the mean of the first
754
+ `period` values (what `wilderValues` runs, and what TA-Lib's own `ATR`
755
+ uses); TA-Lib's `ADX` family instead seeds on the first `period − 1` and
756
+ takes one decayed step, so TA-Lib's `+DI` denominator disagrees with
757
+ TA-Lib's own `ATR` (measured on the oracle input: `1.515450` vs
758
+ `1.411787` at `period 14`). Warm-up masks are identical to TA-Lib's on
759
+ all five columns; the oracle proves the **formula** exactly by replaying
760
+ our pipeline on TA-Lib's seed (`≤ 2.9e-14`) and bounds the pond-seed
761
+ transient separately — at `period 14`, `+DI` 0.117 points at the first
762
+ shared bar decaying to 0.0092 by bar 79, `ADX` 0.421 → 0.0046.
763
+ - **`ADXR` looks back `period − 1` bars**, TA-Lib's reading and the
764
+ package's own bar-count convention (a `period`-bar window spans
765
+ `i − period + 1 … i`). The literal "`period` bars ago" reading differs
766
+ by up to 2.64 points at `period 14`.
767
+ - **`+DI + −DI = 0` → `DX = 0`**, not `undefined`: both legs are
768
+ non-negative, so a zero sum forces a zero numerator (the `clvValues`
769
+ flat-bar rule, not the `percentOfRangeValues` flat-window one). A zero
770
+ **true range** is a genuine `0/0` and does read `undefined`.
771
+ - **`aroon({ period = 25, high?, low?, prefix = 'aroon' })`** → `aroonUp`,
772
+ `aroonDown`, `aroonOsc`. `100 · (period − bars since the extreme) / period`
773
+ on each side, and their difference. **Matches TA-Lib `AROON` and
774
+ `AROONOSC` exactly** (delta `0`, identical masks). The window is
775
+ **`period + 1` bars** — `period` counts the oldest _age_ reportable, and
776
+ "`period` bars ago" is itself a reading — so the warm-up is `period` rows;
777
+ **ties go to the most recent bar** (measured against TA-Lib). Invariant to
778
+ any monotonic rescaling of price, not merely to scale and shift.
779
+ - **`vortex({ period = 14, high?, low?, close?, prefix = 'vi' })`** →
780
+ `viPlus`, `viMinus`. Botes & Siepman's `Σ|high − prevLow| / Σ TR` and
781
+ `Σ|low − prevHigh| / Σ TR`. No TA-Lib function; pandas replication, with
782
+ the plain-bar-range denominator asserted as a visible separation. Both
783
+ legs are positive and **not bounded by 1**; `Σ TR = 0` → `undefined`
784
+ (unlike `DX`, the numerator is not forced to zero with it).
785
+ - **`directionalMovementValues(high, low)`** (`kernels/directional-movement.ts`)
786
+ — Wilder's `+DM`/`−DM` split in one pass, with the tie and sign rules and
787
+ an explicit `NaN` guard (an unknown move must not read as a flat one).
788
+ - **`barsSinceExtremeValues(values, period, 'max' | 'min')`**
789
+ (`kernels/highest-lowest.ts`) — the corpus's **G3 argmax gap**: bars since
790
+ the window's extreme, via a **monotonic deque**, so it is O(N) and _flat in
791
+ `period`_. Measured at 1M bars: 26.7 ms at `period 25` and 26.5 ms at
792
+ `period 200`, against a naive re-scan's 78 ms and 545 ms. `aroon` costs
793
+ 102 ms at 1M bars against `donchian`'s 247 ms — the first measured
794
+ evidence for the monotonic-deque fast path core's rolling min/max wants.
795
+ - `@pond-ts/financial`: **the volatility tail** (assessment §6.5) — seven
796
+ studies of range and drawdown, **none of which TA-Lib implements**, so every
797
+ one is a pandas replication in the oracle with its analytic first-valid bar
798
+ asserted _and_ a measured separation from the plausible wrong turn. Each
799
+ ships with a fluent method, two oracle cases, hand-computed unit values,
800
+ missing-cell placement tests, property tests and a perf entry.
801
+ - **`chaikinVolatility({ period = 10, rocPeriod = 10, high?, low?, output = 'chaikinVol' })`**
802
+ → `chaikinVol`. The percent rate of change of `EMA(high − low, 10)` over
803
+ the last 10 bars — is the average bar getting wider? **Plain** range, not
804
+ true range (Chaikin's; 35.6 points from the true-range version on the
805
+ oracle input). Two separate periods, both defaulting to 10, because the
806
+ smoothing span and the look-back are different quantities. First valid at
807
+ `period − 1 + rocPeriod`.
808
+ - **`massIndex({ emaPeriod = 9, sumPeriod = 25, high?, low?, output = 'mass' })`**
809
+ → `mass`. Dorsey's `Σ EMA(range)/EMA(EMA(range))` — range expansion with no
810
+ direction at all, which is the point of it (the 27 → 26.5 "reversal bulge"
811
+ warns that a trend will turn without saying which way). A **sum**, so a
812
+ steady market reads ≈ `sumPeriod`, not 0 or 1; the mean version is 24.1
813
+ away and is asserted apart. The EMA∘EMA chain follows the `trix` rule, so
814
+ the first valid bar is `2·emaPeriod + sumPeriod − 3` (bar 40 at the
815
+ defaults).
816
+ - **`choppinessIndex({ period = 14, high?, low?, close?, output = 'chop' })`**
817
+ → `chop`, bounded `0..100`. Dreiss' `100·log10(ΣTR/(HH−LL))/log10(period)`:
818
+ path walked against ground covered, **high = choppy, low = trending**, and
819
+ silent about direction. **True** range, the package's own `trueRangeValues`
820
+ (asserted `== talib.TRANGE`), 7.7 points from the plain-range version. The
821
+ log base cancels, which the generator asserts rather than assumes.
822
+ `period` must be **≥ 2** — `log10(1)` is zero.
823
+ - **`ulcerIndex({ period = 14, column?, output = 'ulcer' })`** → `ulcer`.
824
+ Peter Martin's downside-only volatility: the RMS percentage drawdown from
825
+ the window's own highest close. Ships the **rolling (StockCharts) form**;
826
+ Martin's cumulative original is one number per portfolio, not a series, and
827
+ is a documented delta rather than an option — as is the mean-**absolute**
828
+ variant, which is the Pain Index (1.53 away, asserted apart). Warm-up is
829
+ **`2·period − 2`**: two chained windows.
830
+ - **`verticalHorizontalFilter({ period = 28, column?, output = 'vhf' })`** →
831
+ `vhf`, a **fraction** in `(0, 1]`. Adam White's `(HH−LL)/Σ|Δ|` — the
832
+ opposite polarity to `chop`. Both halves read the **same** column, which is
833
+ what makes it composable over another study's output. Warm-up is `period`,
834
+ not `period − 1`: `period` changes need `period + 1` values, and the
835
+ one-term-shorter version is asserted apart.
836
+ - **`gopalakrishnanRangeIndex({ period = 10, high?, low?, output = 'gapo' })`**
837
+ → `gapo`. `ln(HH − LL)/ln(period)` — which _is_ the log of the window's
838
+ range in base `period`, since the base cancels. The **one study in the
839
+ batch that is not scale-invariant**: scaling every price by `k` shifts the
840
+ reading by exactly `ln(k)/ln(period)`, pinned as an identity rather than an
841
+ invariance. `period` must be **≥ 2**.
842
+ - **`relativeVolatilityIndex({ period = 14, stdevPeriod = 10, column?, output = 'relVol' })`**
843
+ → `relVol`, bounded `0..100`. Dorsey's RSI form with the population σ of
844
+ the close in place of the price change, **Wilder-smoothed** (his
845
+ definition; TradingView's EMA-smoothed fork is 31.3 points away and
846
+ measured). **The column is `relVol`, not `rvi`** — that belongs to
847
+ `relativeVigorIndex`, a completely different study, and the two now sit on
848
+ one series without collision. An unchanged close counts as a **down** bar,
849
+ deliberately unlike `rsi`'s split, which gives a flat bar 0 on both legs.
850
+
851
+ **One new kernel**, `barRangeValues` (`kernels/typical-price.ts`, not
852
+ public): the plain `high − low`, shared by `chaikinVolatility` and
853
+ `massIndex`. It is deliberately **not** `trueRangeValues` — the Wilder family
854
+ (ATR, Keltner, Choppiness) takes true range and the Chaikin/Dorsey family
855
+ takes plain range, because that is what each author defined, and neither
856
+ study exposes a knob to swap them.
857
+
858
+ **Two sharp edges worth knowing.** (1) `ulcerIndex` is the first study to
859
+ take a **square root** of a rolling mean, and an incremental accumulator's
860
+ `O(ε)` residue in the mean of squares becomes `1.6e-9` in the reading — right
861
+ where the answer should be exactly `0` (a window at new highs). The
862
+ contributing bars are counted so that reading is exact; the oracle case at
863
+ `period 5` is what caught it. (2) `rollingValues`' answer to a **misnamed
864
+ column** depends on the reducer: `stdev`/`avg` take the range-exact path and
865
+ read all-missing, `max`/`min` fall through to core's sweep and throw. So
866
+ `relativeVolatilityIndex` answers empty where `ulcerIndex` and
867
+ `verticalHorizontalFilter` throw. Both behaviours are pinned by tests and
868
+ documented on the studies; the fix belongs in the kernel.
869
+
870
+ **Zero-denominator guards, where they are and are not.** `choppinessIndex`
871
+ and `gopalakrishnanRangeIndex` divide (or take a logarithm) at their
872
+ **output**, so a non-finite result would reach `withColumn` — which throws on
873
+ `±Infinity` — and their guards are load-bearing. `massIndex` and `ulcerIndex`
874
+ divide **upstream of a rolling summation**, which counts a non-finite cell as
875
+ missing exactly as it counts a `NaN`, so their guards were unobservable and
876
+ were removed after mutation testing; the behaviour (`undefined`, never an
877
+ infinity) is unchanged and still unit-tested. `verticalHorizontalFilter` and
878
+ `relativeVolatilityIndex` need none at all — a zero denominator forces a zero
879
+ numerator in both.
880
+
881
+ - `@pond-ts/financial`: **the volume & money-flow group** (corpus §6.6) — eight
882
+ studies over one new kernel, all in the uniform shape (redirectable bar
883
+ inputs, bar-count periods, length-preserving warm-up, a fluent method) and
884
+ each with oracle cases at two parameterisations where it has a period.
885
+ - **`accumulationDistribution({ high?, low?, close?, volume?, output = 'ad' })`**
886
+ → `ad`. Chaikin's running total of `CLV × volume`, where
887
+ `CLV = ((close − low) − (high − close)) / (high − low)` grades _where in
888
+ its own range_ a bar closed. Like `obv` it has **no period** and no
889
+ warm-up. **Matches TA-Lib `AD` exactly** (delta `0`) on bars with a range.
890
+ - **`chaikinOscillator({ fastPeriod = 3, slowPeriod = 10, high?, low?, close?, volume?, output = 'chaikinOsc' })`**
891
+ → `chaikinOsc`. `EMA(A/D, 3) − EMA(A/D, 10)`, the A/D line's momentum.
892
+ **Matches TA-Lib `ADOSC` exactly** (delta `0`, identical masks) — the one
893
+ EMA-family study here with **no seed delta**, because TA-Lib's own `ADOSC`
894
+ seeds both EMAs on the first A/D value, which is pond's convention. (The
895
+ oracle also asserts the SMA-seeded reconstruction is visibly different, so
896
+ the case pins which seed ships.)
897
+ - **`priceVolumeTrend({ close?, volume?, output = 'pvt' })`** → `pvt`. The
898
+ running total of `volume × (close − prevClose)/prevClose` — OBV's idea
899
+ scaled by _how far_ price moved. The **fraction**, not the percent: it
900
+ composes on the `ROC` kernel and divides by 100, which is the one place
901
+ that constant appears. **`pvt[0]` is `undefined`, not `0`** — the term
902
+ needs a previous close, and unlike OBV there is no vendor convention to
903
+ seed from; every later level is identical either way.
904
+ - **`chaikinMoneyFlow({ period = 20, high?, low?, close?, volume?, output = 'cmf' })`**
905
+ → `cmf`. `Σ CLV·volume / Σ volume` over the window — the A/D term as a
906
+ **bounded** `[−1, +1]` reading. Runs on the same `rollingWeightedMeanValues`
907
+ kernel as `vwap`, so it inherits its edge rules; `Σ volume = 0` →
908
+ `undefined`.
909
+ - **`moneyFlowIndex({ period = 14, high?, low?, close?, volume?, output = 'mfi' })`**
910
+ → `mfi`. The RSI form on raw money flow (`typical price × volume`, split
911
+ by the direction of the typical price). **Matches TA-Lib `MFI`** to
912
+ `2.8e-14` with identical masks. Warm-up is **`period` rows, not
913
+ `period − 1`** (the first bar has no previous typical price), the `rsi` /
914
+ `atr` off-by-one.
915
+ - **`forceIndex({ period = 13, close?, volume?, output = 'force' })`** →
916
+ `force`. Elder's `EMA((close − prevClose) × volume, 13)`; **`period: 1` is
917
+ the raw, unsmoothed force**, so no `smooth: false` flag is needed. No
918
+ `maType` — Elder names the EMA (the `elderRay` precedent).
919
+ - **`easeOfMovement({ period = 14, maType = 'sma', scale = 100_000_000, high?, low?, volume?, output = 'eom' })`**
920
+ → `eom`. Arms' midpoint move divided by the box ratio
921
+ `(volume / scale) / (high − low)`, MA-smoothed. `scale` is StockCharts' /
922
+ ChartIQ's constant and is a **pure linear multiplier**, exposed because the
923
+ right value depends on the instrument's volume units, not on taste. A flat
924
+ bar and a zero-volume bar both report `undefined` (two zero denominators).
925
+ - **`volumeOscillator({ fastPeriod = 5, slowPeriod = 10, maType = 'sma', volume?, output = 'volOsc' })`**
926
+ → `volOsc`. `100 · (MA(volume, 5) − MA(volume, 10)) / MA(volume, 10)`. This
927
+ is **literally `priceOscillator`'s percent mode over the volume column**, so
928
+ it delegates to it rather than restating the arithmetic — a test pins the
929
+ identity. What the wrapper adds is the name and the volume-appropriate
930
+ defaults (5/10/sma against 12/26/ema).
931
+
932
+ **Volume Rate of Change is not a study**: the corpus names it, but
933
+ `percentChange({ column: 'volume' })` _is_ its definition (and is
934
+ TA-Lib-verified through `ROC`), so what ships is a recipe note in API.md and
935
+ a test that pins it — step 0 of the studies README.
936
+
937
+ **One new kernel**, `clvValues` / `accumulationDistributionValues`
938
+ (`kernels/close-location.ts`), shared by A/D, the Chaikin oscillator and CMF.
939
+ A **flat bar (`high === low`) has a close location of exactly `0`** — its
940
+ numerator `(c − l) − (h − c)` is forced to zero, so `0` is the value rather
941
+ than a convention — and so A/D matches TA-Lib's `AD` on every bar, halts
942
+ included, and CMF counts the bar's volume in its denominator as every
943
+ conventional CMF does. A window with **no money flow at all**
944
+ makes `mfi` `undefined` where TA-Lib reports `0` — and TA-Lib reports `0`
945
+ for any window whose total flow is merely below `1.0`, measured returning `0`
946
+ where the answer is `100` on a rising series with `1e-9` volume.
947
+
948
+ - `@pond-ts/financial`: **the momentum tail** (assessment §6.3) — six
949
+ oscillators, all of them ratios rather than price-unit quantities, so all six
950
+ are invariant to **both** a scale factor and a constant shift (pinned by
951
+ property tests, which is the pair that catches a dropped normalisation).
952
+ Each ships with a fluent method, two oracle cases, hand-computed unit values,
953
+ missing-cell placement tests and a perf entry.
954
+ - **`chandeMomentum({ period = 14, column = 'close', output = 'cmo' })`** →
955
+ `cmo`, bounded `−100..100`. `100·(Σup − Σdown)/(Σup + Σdown)` over
956
+ **Chande's unsmoothed** window sums. **This is deliberately not TA-Lib's
957
+ `CMO`**, which Wilder-smooths the legs and is therefore exactly
958
+ `2 · rsi − 100` — a second name for a shipped column. The generator asserts
959
+ that identity (2.8e-14) as the reason, and measures the definition delta:
960
+ 68.28 points at `period 14`, 131.55 at `period 5`, warm-ups identical.
961
+ - **`ultimateOscillator({ shortPeriod = 7, mediumPeriod = 14, longPeriod = 28, high?, low?, close?, output = 'uo' })`**
962
+ → `uo`, bounded `0..100`. Williams' three-horizon blend of buying pressure
963
+ over true range, weighted 4/2/1. Matches **TA-Lib `ULTOSC`** to 7.1e-15 with
964
+ identical masks; the true range is the package's own kernel, asserted equal
965
+ to `talib.TRANGE` exactly. Three **named** periods (not a tuple — the
966
+ weights are positional), validated strictly increasing.
967
+ - **`commodityChannelIndex({ period = 20, high?, low?, close?, output = 'cci' })`**
968
+ → `cci`. `(tp − SMA(tp))/(0.015 · meanAbsDev(tp))`, matching **TA-Lib
969
+ `CCI`** to 3.6e-12. Unbounded — ±100 is a convention, not a limit. A
970
+ zero-deviation window is `undefined` rather than TA-Lib's `0`.
971
+ - **`intradayMomentumIndex({ period = 14, open?, close?, output = 'imi' })`**
972
+ → `imi`, bounded `0..100`. RSI's form over the candle **body**, with
973
+ **plain** window sums rather than Wilder smoothing (Chande's definition; the
974
+ corpus's "RSI form" is the ambiguity this pins). Warm-up is `period − 1`, a
975
+ row shorter than `rsi`/`chandeMomentum`, because a body needs no previous
976
+ bar. Over identical legs `imi = (cmo + 100)/2`, which a test pins.
977
+ - **`relativeVigorIndex({ period = 10, open?, high?, low?, close?, prefix = 'rvi' })`**
978
+ → `rvi` / `rviSignal`. TradingView's definition: ratio of `SWMA(close −
979
+ open)` sums to `SWMA(high − low)` sums, signal a fourth SWMA. **SWMA is the
980
+ symmetric `(1,2,2,1)/6` filter, not the K2 engine's linear `wma(4)`** — a
981
+ test pins the impulse response. The first study to read all four OHLC
982
+ columns.
983
+ - **`psychologicalLine({ period = 12, column = 'close', output = 'psy' })`** →
984
+ `psy`, bounded `0..100` and quantised to multiples of `100/period`. An
985
+ unchanged close is **not** an up bar (strict `>`), and a bar with no close
986
+ leaves both itself and its successor undirected.
987
+
988
+ **Kernels.** Three additions, none public: `rollingMeanAbsDevValues`
989
+ (CCI's denominator — the package's only **O(N·period)** kernel; the cost is
990
+ measured, and the `O(N log period)` order-statistic form that would replace
991
+ it is written down rather than left to be rediscovered),
992
+ `symmetricWeightedValues` (the 4-bar SWMA), and `upDownLegValues` (the
993
+ gain/loss split, now shared by `rsi`, `chandeMomentum` and
994
+ `intradayMomentumIndex` — `rsi` was moved onto it, with no change to its
995
+ numbers).
996
+
997
+ - `@pond-ts/financial`: **five K2 consumers** — the first studies built on the
998
+ moving-average engine, two channels and three smoothed rates. All five take
999
+ the uniform shape (bar-count periods, redirectable inputs, length-preserving
1000
+ warm-up, a fluent method) and each has two oracle cases.
1001
+ - **`keltner({ period = 20, atrPeriod = 10, multiplier = 2, maType = 'ema', high?, low?, close?, prefix = 'kc' })`**
1002
+ → `kcMiddle` / `kcUpper` / `kcLower`. A moving average of **typical price**
1003
+ with bands at ±`multiplier` × **ATR**. The variant is pinned: this is the
1004
+ **modern** form (Chester Keltner via Linda Raschke, and ChartIQ's default) —
1005
+ EMA(20) of typical price ± 2 × ATR(10). Keltner's 1960 original used ±1 ×
1006
+ the SMA of the **plain** high−low range; the half-width here is always
1007
+ _true_ range, which is a documented delta rather than an option.
1008
+ Warm-up is **per column** (the `macd` rule): the centre from the MA's own
1009
+ first bar, the bands from `max(centre, ATR)`.
1010
+ - **`atrBands({ period = 14, multiplier = 2, column = 'close', high?, low?, close?, prefix = 'atrb' })`**
1011
+ → `atrbUpper` / `atrbLower`. **Two** columns, not three: the middle is
1012
+ `column` itself, already on the series. `column` (what the bands are drawn
1013
+ around) is separate from `close` (what the ATR is measured from), so the
1014
+ bands can sit on an `sma` while the volatility still comes off the raw bars.
1015
+ `atrbUpper === column + multiplier × atr()` **bit-for-bit** — both call one
1016
+ shared `atrValues` kernel — and a test pins it against the shipped `atr`.
1017
+ - **`qstick({ period = 8, maType = 'sma', open?, close?, output = 'qstick' })`**
1018
+ → `qstick`. Chande's moving average of the candle body, `close − open`; the
1019
+ candlestick chart's colour, quantified, read against its zero line. The
1020
+ **first study to read `open`**, which joins the named bar inputs.
1021
+ - **`trix({ period = 15, signalPeriod = 9, column?, prefix = 'trix' })`** →
1022
+ `trix` / `trixSignal`. The 1-bar **percent** rate of change of a
1023
+ triple-smoothed EMA, plus an EMA signal (9 is ChartIQ's default; TA-Lib
1024
+ returns the line alone). The line takes the prefix itself, not
1025
+ `${prefix}Line`. It composes **three** EMA passes — TRIX wants `EMA³`, which
1026
+ is _not_ the menu's `tema` (`3·EMA − 3·EMA² + EMA³`), and a test pins the
1027
+ difference.
1028
+ - **`coppock({ longPeriod = 14, shortPeriod = 11, wmaPeriod = 10, column?, output = 'coppock' })`**
1029
+ → `coppock`. `WMA(ROC(14) + ROC(11), 10)`. Coppock's lengths are **months**
1030
+ on a monthly index chart; the study is bar-count like every other here, so
1031
+ on daily bars it is a short-horizon oscillator and not the indicator he
1032
+ defined — stated on the study. The weighted average is part of the
1033
+ definition, so there is no `maType` knob.
1034
+
1035
+ **Oracle.** `trix` is cross-checked against **TA-Lib `TRIX`**: the formula is
1036
+ asserted **bit-exact on TA-Lib's own SMA seed** (2.2e-14, identical null
1037
+ masks — which is what catches a log-vs-percent rate of change, a dropped
1038
+ stage, or a `tema` substitution), with pond's first-sample EMA seed bounded as
1039
+ a decaying transient (8.75% of scale at the first shared bar → 0.85% over the
1040
+ last 20 at `period 15`, against 8.5% / 9.8% for the wrong smoothing rates).
1041
+ TA-Lib has no Keltner, ATR-band, QStick or Coppock function, so those four are
1042
+ pandas replications — but the ATR half-width and the rates of change reuse the
1043
+ references TA-Lib already validates, so what each case adds is the assembly
1044
+ and its composed warm-up, each asserted as an analytic first-valid bar. The
1045
+ oracle fixture gains an **`opens`** column (the previous close pulled inside
1046
+ its own bar; 30 of 80 bodies negative, all 80 distinct).
1047
+
1048
+ - `@pond-ts/financial`: **five price-vs-moving-average oscillators** — the
1049
+ first consumers of the K2 moving-average engine. All five ship with fluent
1050
+ methods, oracle cases at two parameterisations each, and property tests.
1051
+ - **`priceOscillator({ fastPeriod = 12, slowPeriod = 26, maType = 'ema',
1052
+ mode = 'percent', column = 'close', output = 'priceOsc' })`** — the
1053
+ spread between two moving averages, as a percent of the slow one
1054
+ (TA-Lib's **PPO**) or in price units (`mode: 'absolute'`, TA-Lib's
1055
+ **APO**). **Percent is the default** for two reasons: the absolute form
1056
+ at the default parameters is `macd()`'s line bar-for-bar (a test pins the
1057
+ identity), and the percent form is the scale-invariant, cross-instrument
1058
+ comparable one. `{ maType: 'sma', mode: 'absolute' }` matches TA-Lib's
1059
+ `APO(matype=0)` to `7.1e-14` with an identical mask; on `'ema'` pond's
1060
+ first-sample EMA seed is kept (the `macd` precedent), so the generator
1061
+ proves the **formula** on TA-Lib's own SMA seed (`2.8e-14`) and bounds
1062
+ the **seed transient** separately — 6.61% of scale at the first shared
1063
+ bar, 0.41% at its worst over the last 20.
1064
+ - **`disparityIndex({ period = 14, maType = 'sma', column, output =
1065
+ 'disparity' })`** — `100·(price − MA)/MA`, scale-invariant. No TA-Lib
1066
+ function; pandas replication with the analytic first-valid bar asserted.
1067
+ - **`detrendedPriceOscillator({ period = 20, maType = 'sma', column,
1068
+ output = 'dpo' })`** — `price[i] − MA[i − shift]` with
1069
+ `shift = ⌊period/2⌋ + 1` (it **floors** on odd periods). This is the
1070
+ **non-centered** alignment (TradingView's default); StockCharts'
1071
+ centered `price[i − shift] − MA[i]` is a different series, not a
1072
+ re-plotting of this one, and is documented as such on the study. First
1073
+ value on bar `period − 1 + shift` (bar 30 at the defaults).
1074
+ - **`elderRay({ period = 13, high, low, close, prefix = 'elder' })`** —
1075
+ appends `elderBull` = `high − EMA(close)` and `elderBear` =
1076
+ `low − EMA(close)`. Elder's definition names the EMA, so there is no
1077
+ `maType` knob. No TA-Lib function; pandas replication on pond's EMA seed.
1078
+ - **`awesomeOscillator({ fastPeriod = 5, slowPeriod = 34, high, low,
1079
+ output = 'ao' })`** — `SMA(5) − SMA(34)` of the median price
1080
+ `(high + low)/2`. Both legs wait for that many finite **values** (the
1081
+ derived-array rule), so a gap masks the windows containing it. No TA-Lib
1082
+ function; pandas replication.
1083
+
1084
+ Internally this adds one kernel helper, `medianPriceValues(high, low)`,
1085
+ beside `typicalPriceValues` — 7.6 ms at 1M bars, and named consumers next
1086
+ (Alligator, Gator, High-Low Bands). It is not a public export.
1087
+
1088
+ - `@pond-ts/financial`: **the K2 moving-average engine** — one **MA-type
1089
+ vocabulary** shared by every study that exposes a "MA Type" input (~25 of
1090
+ them in the corpus assessment). New study **`movingAverage({ period, type =
1091
+ 'sma', column = 'close', output = 'ma' })`** with fluent
1092
+ `.movingAverage()`, new exported type **`MaType`** and its **`MA_TYPES`**
1093
+ menu, and the raw-array kernel **`movingAverageValues(values, period,
1094
+ type)`** for studies whose input is derived rather than a column (Keltner's
1095
+ typical price, Coppock's WMA of two ROCs, the Price Oscillator).
1096
+
1097
+ Ten types: `sma`, `ema`, `wma` (linear weights), `smma` (Wilder/RMA),
1098
+ `dema` (`2·EMA − EMA²`), `tema` (`3·EMA − 3·EMA² + EMA³`), `trima`
1099
+ (TA-Lib's SMA-of-SMA, lengths summing to `period + 1`), `hull`
1100
+ (`WMA(2·WMA(n/2) − WMA(n), round √n)`), `kama` (Kaufman, TA-Lib's fast 2 /
1101
+ slow 30) and `zlema` (`EMA(2x − x[i−lag])`, `lag = ⌊(period−1)/2⌋` — it
1102
+ floors, so `zlema(10)` and `zlema(11)` share a lag). Deferred, and named as
1103
+ deferred: MAMA/FAMA, T3, VIDYA (needs the K6 recursion) and the
1104
+ time-series/regression forecast MA (kernel K7).
1105
+
1106
+ **`type: 'sma'` and `type: 'ema'` are the calls `sma()` and `ema()` already
1107
+ make** — routed back to `rollingValues` / `smooth('ema')`, so they are the
1108
+ same doubles rather than a second implementation, and stay on their
1109
+ accelerated paths. Pinned bit-for-bit by test.
1110
+
1111
+ **Oracle**: every type has cases at two periods. `sma` / `wma` / `trima` /
1112
+ `kama` match TA-Lib's `MA(matype=…)` **exactly** (≤ 5.7e-13, identical null
1113
+ masks; `kama` to `0`). `ema` / `dema` / `tema` keep **pond's first-sample
1114
+ EMA seed** rather than TA-Lib's SMA seed — the `macd` precedent, so
1115
+ `movingAverage({ type: 'ema' })` cannot disagree with `ema()` inside our own
1116
+ package. Masks are still identical; the values are a decaying seed
1117
+ transient, measured at 0.210% / 0.529% / 0.059% of scale at the first shared
1118
+ bar (period 21) falling to 0.0008% / 0.0155% / 0.0144% by bar 79, and
1119
+ asserted as a tail bound. `smma` / `hull` / `zlema` have no TA-Lib function
1120
+ and are pandas replications with their analytic warm-up asserted (`smma`
1121
+ also pinned bit-for-bit against the `wilderValues` kernel RSI and ATR run
1122
+ on).
1123
+
1124
+ **Interior gaps differ by type, deliberately** (the Wilder asymmetry, now
1125
+ stated per type on the kernel): the window types recover once the gap leaves
1126
+ the window, the `ema` family skips the missing bar and carries on, and
1127
+ `smma` and `kama` propagate to the end — a recursion that consumes every bar
1128
+ has no state to carry across a hole. `sma` is also the one type a leading
1129
+ gap does **not** shift, because it keeps `sma()`'s row-counting window.
1130
+
1131
+ ### Changed
1132
+
1133
+ - `@pond-ts/financial`: **`envelope`'s `maType` widens from `'sma' | 'ema'` to
1134
+ the full `MaType` menu** and routes through the shared engine. Strictly
1135
+ additive for existing callers — the `sma` and `ema` centre lines are the
1136
+ same kernel calls as before, pinned bit-for-bit — and an unknown `maType`
1137
+ now throws instead of silently falling back to SMA. (On the raw-array door,
1138
+ `movingAverageValues(…, 'sma')` waits for `period` finite values like every
1139
+ other type; the column door keeps `sma()`'s rows window.)
1140
+ - `pond-ts`: `partitionBy(column, options)` now throws a `TypeError` on an
1141
+ unknown option key (e.g. `maxPartitions`, which was never implemented)
1142
+ instead of silently ignoring it. Only JS callers passing a key the type
1143
+ rejects are affected; the error names the known keys ([PND-LIVFIX]).
1144
+
1145
+ ### Fixed
1146
+
1147
+ - `pond-ts` live layer, the [PND-LIVFIX] cluster — the five confirmed
1148
+ wrong-answer defects from the 2026-06 audits, each now pinned by a test:
1149
+ - **Listener errors are isolated.** A `LiveSeries` / `LiveView` listener
1150
+ that throws no longer aborts the push: every row still commits, the
1151
+ remaining listeners still run, retention still runs and `'batch'` /
1152
+ `'evict'` still fire; the first error is rethrown to the caller once
1153
+ the push has completed. Before, a throw skipped retention (the buffer
1154
+ sat over `maxEvents` until the next push) and left every later
1155
+ subscriber — a derived `filter()` view included — permanently out of
1156
+ sync.
1157
+ - **Re-entrancy.** A listener may subscribe or unsubscribe during
1158
+ dispatch without disturbing the fan-out (a listener added mid-event
1159
+ does not fire for that event), and a `push` made from inside a
1160
+ listener is queued and delivered after the current push, in order.
1161
+ Before, a re-entrant push emitted out of order, was spuriously
1162
+ rejected as out-of-order on the chunked backing, and broke an attached
1163
+ view with an error that read `[object Object]` (keys in that message
1164
+ now print as ISO timestamps).
1165
+ - **Quiet partitions evict by age.** Per-partition `maxAge` was
1166
+ push-driven, so a partition that stopped receiving events kept them
1167
+ forever. `partitionBy` now sweeps every partition against the source
1168
+ watermark as it advances (throttled to `maxAge / 8`), emitting
1169
+ `'evict'` as push-driven retention does.
1170
+ - **Chain-aware `LiveView.dispose()`.** Disposing the last view of
1171
+ `live.filter(p).map(f)` now disposes the unreachable intermediate too
1172
+ (a source view left with no subscribers is torn down; one with another
1173
+ subscriber is not). `dispose()` is idempotent and documented.
1174
+ - **`LiveReduce` windowed reducers on a `reorder` source with
1175
+ retention.** `min` / `max` / `first` / `last` reported stale or
1176
+ `undefined` values because their sliding-window state assumed
1177
+ oldest-arrived-first eviction; over a `reorder` source they now use
1178
+ removal-by-any-index structures (exact in any eviction order, O(n)
1179
+ per update, selected only on that source shape).
1180
+
71
1181
  ## [0.65.0] — 2026-09-06
72
1182
 
73
1183
  ### Added