pond-ts 0.63.0 → 0.65.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.
package/API.md CHANGED
@@ -164,7 +164,7 @@ Deliberately small — the ordering-based slice of the algebra, no calendar ops
164
164
  | Operation schemas | `RollingSchema`, `RollingAlignment`, `AlignSchema`, `DiffSchema`, `SmoothSchema`, `SmoothMethod`, `FillStrategy`, `FillMapping` | `packages/core/src/schema/index.ts` |
165
165
  | Column/data kinds | `Column`, `KeyColumn`, `ColumnKind`, `ScalarKind`, `ScalarValue`, `ColumnValue`, `ArrayValue`, `ValidityBitmap` | `packages/core/src/columnar/` |
166
166
  | JSON wire format | `JsonRowFormat`, `JsonRowForSchema`, `JsonObjectRowForSchema`, `JsonValueForKind`, `JsonTimestampInput`, `JsonTimeRangeInput`, `JsonIntervalInput` | `packages/core/src/schema/index.ts` |
167
- | Temporal utility | `TemporalLike`, `DurationInput`, `CalendarUnit`, `TimeZoneOptions`, `KeyLike`, `BatchSampleStrategy` | `packages/core/src/core/`, `packages/core/src/sequence/` |
167
+ | Temporal utility | `TemporalLike`, `DurationInput`, `CalendarUnit`, `TimeZoneOptions`, `KeyLike`, `BatchSampleStrategy`, `SequenceSample`, `SequenceCoverage` | `packages/core/src/core/`, `packages/core/src/sequence/` |
168
168
 
169
169
  The `pond-ts/types` subpath re-exports the schema-as-contract types with zero
170
170
  runtime (`packages/core/src/schema/public.ts`).
@@ -432,19 +432,32 @@ Series shapes (same file): `ChartSeries`, `BandSeries`, `BoxSeries`,
432
432
 
433
433
  All are pure `(series, options) → TimeSeries` appending output columns;
434
434
  `column` defaults to `'close'`; periods are bar counts; warm-up is
435
- length-preserving (`undefined` head rows).
436
-
437
- | Study | Output column(s) | Options gist | Source |
438
- | --------------------------- | ----------------------------------- | --------------------------------------------------- | -------------------------------------------------- |
439
- | `sma` | `sma` | `{ period, column?, output? }` | `packages/financial/src/studies/moving-average.ts` |
440
- | `ema` | `ema` | `{ period, column?, output? }` (α = 2/(period+1)) | `packages/financial/src/studies/moving-average.ts` |
441
- | `bollinger` | `bbMiddle`, `bbUpper`, `bbLower` | `{ period, stdDev?, column?, prefix? }` | `packages/financial/src/studies/bollinger.ts` |
442
- | `envelope` | `envMiddle`, `envUpper`, `envLower` | `{ period, percent?, maType?, column?, prefix? }` | `packages/financial/src/studies/envelope.ts` |
443
- | `rollingStdev` | `stdev` | `{ period, column?, output? }` (population, ddof=0) | `packages/financial/src/studies/rolling-stat.ts` |
444
- | `rollingMin` / `rollingMax` | `min` / `max` | `{ period, column?, output? }` (Donchian edges) | `packages/financial/src/studies/rolling-stat.ts` |
445
- | `rollingPercentile` | `p{q}` (e.g. `p90`) | `{ period, q, column?, output? }` | `packages/financial/src/studies/rolling-stat.ts` |
446
- | `zScore` | `zscore` | `{ period, column?, output? }` | `packages/financial/src/studies/z-score.ts` |
447
- | `percentChange` | `pctChange` | `{ periods?, column?, output? }` | `packages/financial/src/studies/percent-change.ts` |
435
+ length-preserving (`undefined` head rows). A **multi-input** study (`atr`)
436
+ names each input instead of taking one `column`, each defaulting to its
437
+ `DEFAULT_OHLCV` name the same "never hard-code a column" rule applied
438
+ per input.
439
+
440
+ | Study | Output column(s) | Options gist | Source |
441
+ | --------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- | -------------------------------------------------- |
442
+ | `sma` | `sma` | `{ period, column?, output? }` | `packages/financial/src/studies/moving-average.ts` |
443
+ | `ema` | `ema` | `{ period, column?, output? }` (α = 2/(period+1)) | `packages/financial/src/studies/moving-average.ts` |
444
+ | `bollinger` | `bbMiddle`, `bbUpper`, `bbLower` | `{ period, stdDev?, column?, prefix? }` | `packages/financial/src/studies/bollinger.ts` |
445
+ | `envelope` | `envMiddle`, `envUpper`, `envLower` | `{ period, percent?, maType?, column?, prefix? }` | `packages/financial/src/studies/envelope.ts` |
446
+ | `rollingStdev` | `stdev` | `{ period, column?, output? }` (population, ddof=0) | `packages/financial/src/studies/rolling-stat.ts` |
447
+ | `rollingMin` / `rollingMax` | `min` / `max` | `{ period, column?, output? }` (one edge; `donchian` gives the channel) | `packages/financial/src/studies/rolling-stat.ts` |
448
+ | `rollingPercentile` | `p{q}` (e.g. `p90`) | `{ period, q, column?, output? }` | `packages/financial/src/studies/rolling-stat.ts` |
449
+ | `zScore` | `zscore` | `{ period, column?, output? }` | `packages/financial/src/studies/z-score.ts` |
450
+ | `percentChange` | `pctChange` | `{ periods?, column?, output? }` (= ROC; TA-Lib-verified) | `packages/financial/src/studies/percent-change.ts` |
451
+ | `rsi` | `rsi` | `{ period?, column?, output? }` (Wilder, default 14) | `packages/financial/src/studies/rsi.ts` |
452
+ | `macd` | `macdLine`, `macdSignal`, `macdHist` | `{ fastPeriod?, slowPeriod?, signalPeriod?, column?, prefix? }` (12/26/9) | `packages/financial/src/studies/macd.ts` |
453
+ | `atr` | `atr` | `{ period?, high?, low?, close?, output? }` (Wilder, default 14) | `packages/financial/src/studies/atr.ts` |
454
+ | `momentum` | `momentum` | `{ period?, column?, output? }` (`v − v[−period]`, default 10) | `packages/financial/src/studies/momentum.ts` |
455
+ | `historicalVolatility` | `hv` | `{ period?, annualize?, column?, output? }` (σ of log returns, ×√252) | `packages/financial/src/studies/volatility.ts` |
456
+ | `stochastic` | `stochK`, `stochD` | `{ kPeriod?, slowing?, dPeriod?, high?, low?, close?, prefix? }` (14/3/3; `slowing: 1` = fast) | `packages/financial/src/studies/stochastic.ts` |
457
+ | `williamsR` | `williamsR` | `{ period?, high?, low?, close?, output? }` (default 14, bounded −100..0) | `packages/financial/src/studies/williams-r.ts` |
458
+ | `donchian` | `dcUpper`, `dcLower`, `dcMiddle` | `{ period?, high?, low?, prefix? }` (default 20) | `packages/financial/src/studies/donchian.ts` |
459
+ | `obv` | `obv` | `{ close?, volume?, output? }` (no period; TA-Lib seed `volume[0]`) | `packages/financial/src/studies/obv.ts` |
460
+ | `vwap` | `vwap` | `{ period, high?, low?, close?, volume?, output? }` (rolling, typical px) | `packages/financial/src/studies/vwap.ts` |
448
461
 
449
462
  Adding a study? Follow `packages/financial/src/studies/README.md` (uniform
450
463
  shape + pandas oracle case + fluent method are all REQUIRED).
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.63.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.65.0...HEAD
12
+ [0.65.0]: https://github.com/pond-ts/pond/compare/v0.64.0...v0.65.0
13
+ [0.64.0]: https://github.com/pond-ts/pond/compare/v0.63.0...v0.64.0
12
14
  [0.63.0]: https://github.com/pond-ts/pond/compare/v0.62.0...v0.63.0
13
15
  [0.62.0]: https://github.com/pond-ts/pond/compare/v0.61.0...v0.62.0
14
16
  [0.61.0]: https://github.com/pond-ts/pond/compare/v0.60.0...v0.61.0
@@ -66,6 +68,287 @@ include new features and type-level changes; patch bumps are strictly additive.
66
68
 
67
69
  ## [Unreleased]
68
70
 
71
+ ## [0.65.0] — 2026-09-06
72
+
73
+ ### Added
74
+
75
+ - `@pond-ts/financial`: **`stochastic(...)`**, **`williamsR(...)`** and
76
+ **`donchian(...)`** — the three range-position studies, built on one new
77
+ kernel (`highestLowestValues`: the trailing highest high / lowest low in a
78
+ single `rolling` scan) plus fluent `.stochastic()` / `.williamsR()` /
79
+ `.donchian()`. All name their bar inputs per column (`high` / `low` /
80
+ `close`, defaulting to `DEFAULT_OHLCV`), the `atr` shape.
81
+
82
+ **`stochastic`** — `{ kPeriod = 14, slowing = 3, dPeriod = 3, prefix =
83
+ 'stoch' }` appending `stochK` / `stochD`: the slow stochastic, with
84
+ `slowing: 1` giving the fast one (TA-Lib's `STOCHF`) from the same
85
+ function. Values match TA-Lib's `STOCH`/`STOCHF` to `5.7e-14` on every bar
86
+ TA-Lib emits, `%D` with an identical warm-up. Two deliberate deltas,
87
+ both documented on the study: **`%K` starts when it can** (bar 15 at the
88
+ defaults) where TA-Lib masks it back to `%D`'s first bar (17), discarding
89
+ `dPeriod − 1` real values — the `macd` precedent; and **a flat window
90
+ (`HH === LL`) is `undefined`** where TA-Lib reports `0`, which is also its
91
+ value for "close at the very bottom of a real range" — the `rsi` precedent.
92
+ The smoothing uses a new raw-array kernel, `rollingMeanValues`, that waits
93
+ for `slowing` _values_ rather than rows: a scratch-column SMA would have
94
+ put a "3-bar" `%K` on bar 13 with one value in it.
95
+
96
+ **`williamsR`** — `{ period = 14, output = 'williamsR' }`, bounded
97
+ `−100..0`. Matches TA-Lib's `WILLR` bar-for-bar to `1.4e-14` with an
98
+ identical warm-up; the only delta is the same flat-window `undefined`.
99
+ It is fast `%K − 100`, computed on the same kernel, and a test pins that
100
+ identity against `stochastic({ slowing: 1 })`.
101
+
102
+ **`donchian`** — `{ period = 20, prefix = 'dc' }` appending `dcUpper` /
103
+ `dcLower` / `dcMiddle`. `upper` is exactly `rollingMax(high)` and `lower`
104
+ exactly `rollingMin(low)` (pinned), in one scan plus the midpoint; pandas
105
+ oracle only (TA-Lib has no Donchian). Scales linearly with price, like
106
+ `atr`; a missing `high`/`low` is skipped (core's reducer policy), not
107
+ propagated.
108
+
109
+ - `@pond-ts/financial`: **`obv(...)`** and **`vwap(...)`** — the first two
110
+ **volume** studies, plus fluent `.obv()` / `.vwap()`. Both name their inputs
111
+ per column (`close` / `volume`, and `high` / `low` for VWAP), each
112
+ defaulting to its `DEFAULT_OHLCV` name, the rule `atr` established.
113
+
114
+ **`obv`** is Granville's On-Balance Volume — the running total of each bar's
115
+ volume signed by its close change — and is the first study with **no
116
+ `period`**: it is read for its shape, not its level, so there is nothing to
117
+ size a window over. It is **TA-Lib's OBV exactly**, including the seed
118
+ (`OBV[0] = volume[0]`) and an unchanged close adding nothing; the oracle
119
+ asserts equality, with an identical (empty) warm-up mask. On **interior
120
+ gaps** it deliberately departs from TA-Lib: a running sum has no local
121
+ answer for a missing term, so a missing close or volume leaves the level
122
+ `undefined` from that bar on (the same asymmetry the Wilder recursion has —
123
+ a leading gap shifts the seed, an interior one propagates). TA-Lib has no
124
+ gap semantics; measured on a seven-bar series it is 400 out of level at
125
+ the gap bar and 100 out from then on, presented as a value.
126
+
127
+ **`vwap`** is a **rolling** Volume-Weighted Average Price:
128
+ `Σ typicalPrice · volume / Σ volume` over `period` bars, typical price
129
+ `(high + low + close) / 3`. There is no TA-Lib VWAP and no single
130
+ definition, so this is a documented design choice: the rolling form fits
131
+ the package's count-window shape, and the intraday desk's _anchored_ VWAP
132
+ is not a special case of it (a count window emits only once it spans
133
+ `period` rows) — that one needs a session reset and is deferred to the
134
+ session-anchored phase. `period` is **required** (no conventional length to
135
+ default to). A window with no volume is `undefined`; a bar missing any
136
+ input is dropped from **both** sums so the ratio is never biased. Verified
137
+ against a pandas replication on a fixture whose volume spikes make a
138
+ dropped weighting visible (≥ 1.24 price units from the plain mean).
139
+
140
+ The oracle fixture gains a `volumes` input, and four kernels land under
141
+ them for the studies that follow: `cumulativeValues` (A/D line, PVT),
142
+ `signedVolumeValues`, `typicalPriceValues` (CCI, MFI, Keltner) and
143
+ `rollingWeightedMeanValues` (VWMA).
144
+
145
+ - `@pond-ts/financial`: **`atr(...)`** — Wilder's Average True Range, the third
146
+ [PND-STUDY] named indicator, plus a fluent `.atr()`. `{ period = 14,
147
+ high = 'high', low = 'low', close = 'close', output = 'atr' }`. Verified
148
+ against **TA-Lib** bar-for-bar in the oracle — exact agreement at both
149
+ periods tested, with identical warm-up.
150
+
151
+ **The first multi-input study.** Where every study so far took a single
152
+ `column`, ATR names each of its three inputs, each defaulting to its
153
+ `DEFAULT_OHLCV` name — the same "never hard-code a column" rule applied per
154
+ input rather than a new mechanism. Because the inputs are columns of one
155
+ series they are aligned by construction, so the mismatched-length error
156
+ array-based libraries must check for at every call is unrepresentable here.
157
+
158
+ Warm-up is `period` rows, not `period − 1`: true range needs a previous
159
+ close, so bar 0 has none.
160
+
161
+ - `@pond-ts/financial`: **`macd(...)`** — Moving Average Convergence/Divergence,
162
+ the second [PND-STUDY] named indicator. `{ fastPeriod = 12, slowPeriod = 26,
163
+ signalPeriod = 9, column = 'close', prefix = 'macd' }`, appending
164
+ `macdLine` / `macdSignal` / `macdHist`, plus a fluent `.macd()`.
165
+
166
+ Two deliberate deltas from TA-Lib, both documented on the study:
167
+ **each column warms up when it can** (the line at bar 25, signal and
168
+ histogram at 33) where TA-Lib masks all three back to 33, keeping eight real
169
+ values it discards; and the EMAs are **pond's own** (`α = 2/(span+1)` seeded
170
+ on the first sample — what `ema()` already ships) rather than TA-Lib's
171
+ SMA-seeded ones. The second is a _seed_ difference, so it decays: measured
172
+ at 3.8% of the line's magnitude at the first shared bar, 0.089% by bar 79.
173
+ Seeding TA-Lib's way would make `macd()` disagree with
174
+ `ema(fast) − ema(slow)` inside this package, which is the worse surprise —
175
+ and is pinned by a test.
176
+
177
+ - `@pond-ts/financial`: **`rsi(...)`** — Wilder's Relative Strength Index, the
178
+ first of the [PND-STUDY] named indicators. `{ period = 14, column = 'close',
179
+ output = 'rsi' }`, length-preserving warm-up (`period` rows, not `period − 1`
180
+ — RSI averages _differences_, so it needs one extra bar), and a fluent
181
+ `.rsi()`. Verified against **TA-Lib** bar-for-bar in the oracle fixture,
182
+ agreeing to `1.4e-14`.
183
+
184
+ Worth knowing which RSI this is: the gain/loss averages are seeded on the
185
+ arithmetic mean of the first `period` differences, then carried by Wilder's
186
+ recursion — TA-Lib's definition and Wilder's original. Implementations that
187
+ instead smooth with a plain first-sample-seeded EMA of the same `α = 1/period`
188
+ produce a **materially different** series: on this package's own oracle input
189
+ the two sit up to **7.03 RSI points** apart, and are still `0.15` apart 65
190
+ bars later. That is enough to move a reading across the conventional 70/30
191
+ thresholds, so it is a definition choice rather than a rounding detail.
192
+
193
+ - `@pond-ts/financial`: **`momentum(...)`** — the absolute `period`-bar
194
+ difference, `value − value[i − period]`. `{ period = 10, column = 'close',
195
+ output = 'momentum' }`, warm-up `period` rows, plus a fluent `.momentum()`.
196
+ Verified against **TA-Lib's `MOM`** bar-for-bar in the oracle: exact
197
+ agreement with identical warm-up masks. The additive companion to
198
+ `percentChange`'s ratio; it is in the price's units and scales with them
199
+ (pinned by a property test).
200
+
201
+ - `@pond-ts/financial`: **`historicalVolatility(...)`** — the standard
202
+ deviation of log returns over `period` bars, annualised.
203
+ `{ period = 20, annualize = 252, column = 'close', output = 'hv' }`, plus a
204
+ fluent `.historicalVolatility()`. Warm-up is `period` rows, not
205
+ `period − 1` — `period` returns need `period + 1` prices, the same
206
+ off-by-one RSI and ATR have.
207
+
208
+ TA-Lib has no HV to arbitrate the conventions, so they are pinned on the
209
+ study and in the pandas oracle (`np.log(s).diff().rolling(n).std(ddof=0)
210
+ · √annualize`): **population σ** (`ddof = 0`, the package convention shared
211
+ with Bollinger / `rollingStdev` / `zScore`; a sample σ is
212
+ `√(n/(n−1))` larger — 2.6% at 20); **log returns**, not simple; annualised
213
+ by `√annualize` with `252` the default for daily bars and an **option**
214
+ rather than a hidden constant (`1` for per-bar σ, `252·390` for one-minute
215
+ bars); and a **decimal** (`0.18` = 18%), the form volatility is consumed
216
+ in. Scale-invariant, pinned by a property test. A non-positive price has
217
+ no log return: both returns touching it are missing (explicitly, since
218
+ `ln(−4/−5)` is a finite non-answer). A leading gap shifts the start so the
219
+ first window still covers `period` real returns, matching pandas'
220
+ `min_periods` and the Wilder-family studies — the σ is computed by calling
221
+ the range-exact kernel on the returns array directly, because a scratch
222
+ column through `rollingValues` counts rows and would emit one bar early
223
+ over one return too few.
224
+
225
+ - `@pond-ts/financial`: **`percentChange` is ROC** — TA-Lib's `ROC` is the same
226
+ `(price / prevPrice − 1) × 100`, and the oracle now cross-checks the study
227
+ against it: exact agreement (`0.0`) with identical warm-up masks at both
228
+ periods. Documented on the study and in API.md; there is deliberately no
229
+ separate `roc` study.
230
+
231
+ ## [0.64.0] — 2026-08-28
232
+
233
+ ### Added
234
+
235
+ - `pond-ts`: **`Sequence.bounded(range, { coverage })`** — chooses whether a
236
+ range selects buckets by their sample point (`'sample'`, the default and the
237
+ existing behaviour) or by their extent (`'overlap'`, every bucket the range
238
+ touches, including the one containing `range.begin()`). `aggregate` realizes
239
+ its grid with `'overlap'`; alignment keeps `'sample'`. As a side benefit
240
+ `bounded({ start: t, end: t }, { coverage: 'overlap' })` is a flooring
241
+ primitive — it returns exactly the bucket containing `t` — so consumers no
242
+ longer have to re-derive pond's calendar anchoring by hand to floor a
243
+ timestamp. New exported type `SequenceCoverage`.
244
+
245
+ - `@pond-ts/charts`: **`<YAxis zeroAnchored>`** — pins a y-axis's zoom to the
246
+ value-`0` gridline instead of the pointer, and drops drag-to-pan entirely.
247
+ For a bar chart, whose baseline must never move: a bar rests on `0` (at the
248
+ plot floor for all-positive data, or somewhere mid-plot for data that
249
+ straddles positive and negative), and an ordinary pointer-pivoted gesture
250
+ would slide that baseline around the plot as you scroll. With
251
+ `zeroAnchored`, every wheel notch scales the axis around wherever `0`
252
+ currently renders, so the baseline holds however far in or out you zoom.
253
+ One qualification: the pivot is clamped into the axis's pixel range, so once
254
+ `0` has been scrolled off the plot the zoom pivots about the nearer edge and
255
+ the baseline does creep — benign for bars, where that clamp coincides with
256
+ the declared floor, but not an unconditional guarantee. Still gated by the
257
+ container's own `axisPanZoom` opt-in — this changes what the gesture does,
258
+ not whether it's on.
259
+
260
+ ### Changed
261
+
262
+ - `pond-ts`: **`aggregate` now emits the bucket containing the first event**,
263
+ where before it started at the first grid boundary _at or after_ it — so
264
+ every event between the two aggregated into nothing, silently. 60 daily bars
265
+ rolled to a calendar month came back holding 38 of them: no error, no
266
+ warning, no `undefined`, just a well-formed series with a third of the input
267
+ gone. It contradicted `aggregate`'s own documented membership rule ("point
268
+ events contribute to the bucket containing their timestamp") and was
269
+ asymmetric — the trailing partial bucket _was_ emitted. **This changes
270
+ results for any caller whose first event did not land exactly on a grid
271
+ boundary**: expect an extra leading bucket, and sums/counts that now account
272
+ for the previously-dropped events. Callers who worked around it by passing a
273
+ pre-floored explicit `range` are unaffected — the floor is now what the
274
+ default does. **Note what `range` does and does not bound:** it selects the
275
+ grid, not the event scan, so the leading bucket is filled from every event it
276
+ contains — including events _before_ `range.begin()` when the series has
277
+ them. On a window narrower than the series (a chart viewport, say) the edge
278
+ bucket is therefore a complete bucket rather than a clipped one, which is
279
+ what keeps bar heights stable under a pan; if you need the window to bound
280
+ the events too, narrow the series rather than the grid. `align(...)` and
281
+ `materialize(...)` are deliberately unchanged: their grids are _sampled_,
282
+ and the sample point becomes the output key. A pre-realized
283
+ `BoundedSequence` argument is an explicit bucket list, used as given and
284
+ unaffected by any of this. Reported by Tidal (#672).
285
+
286
+ - `@pond-ts/charts`: **y gutter drag now pans instead of zooming** — matching
287
+ the x strip's own gesture (drag pans, wheel zooms) instead of the
288
+ drag-to-zoom behavior it shipped with in `axisPanZoom="y"`/`"xy"`. Wheel
289
+ still zooms the grabbed axis about the pointer, and double-click still
290
+ releases it back to its fit; only the drag's own effect changed.
291
+
292
+ ### Fixed
293
+
294
+ - `@pond-ts/charts`: **a trading-time date band clamped onto a labelled tick
295
+ keeps its segment boundary.** The F-charts-21 collision fix dropped the whole
296
+ `{ start, label, shaded }` entry, but `<XAxis>` spans each band
297
+ `[its start, the next band's start)` and takes its zebra parity from
298
+ `shaded` — so removing an entry deleted the _period_, not a duplicate label,
299
+ and the preceding band ran on to the next surviving boundary under its own
300
+ name and shading. (Aug 1 2026 is a Saturday, so the August band clamps onto
301
+ Mon Aug 3, already a day tick — August vanished and July painted across it.)
302
+ `bands()` entries now carry **`showLabel`**, and only the text is suppressed.
303
+ Found by Codex reviewing the v0.64.0 range.
304
+ - `@pond-ts/charts`: **a controlled y-gutter wheel zoom no longer pivots about
305
+ the wrong point under a plot-level y zoom.** The `zeroAnchored` pivot was
306
+ corrected above, but the ordinary (non-anchored) controlled wheel still fed
307
+ the pointer's _screen_ pixel into the pre-transform scale's pixel space, so
308
+ the value under the pointer slid out from under it — ~0.14 units per notch at
309
+ `k = 1.198`. Third and last face of one root cause: a screen-space pixel used
310
+ where `base`'s pixel space is meant.
311
+ - `@pond-ts/charts`: **a controlled y-gutter pan no longer overshoots the
312
+ cursor under a plot-level y zoom.** With `onBoundsChange` set, the drag
313
+ translated the axis's pre-transform scale by a _screen_ pixel delta. Those
314
+ two pixel spaces differ by the container's `yTransform`, so with the plot's
315
+ own y pan/zoom engaged (`panZoom="panZoomY"` / `"panZoomXY"`) the axis
316
+ panned `k`× too far and the grabbed value outran the pointer. Both
317
+ transforms are applied in pixel space, so the base↔screen relation is
318
+ exactly affine and the correction is a division, on `log` and `symlog` as
319
+ well. (The division is exact on all three; end-to-end tracking on `symlog`
320
+ still drifts, because its knee is re-derived from the panned domain — a
321
+ pre-existing property the sibling zoom test already documents.) Invisible at
322
+ `k === 1`.
323
+ - `@pond-ts/charts`: **`<YAxis zeroAnchored>` no longer lets the baseline drift
324
+ in the controlled path.** With `onBoundsChange` set, the zoom read `0`'s
325
+ pixel off the axis's _live_ scale but inverted it through `baseYScales`.
326
+ Those two pixel spaces differ by the container's own `yTransform`, so with
327
+ the plot's y pan/zoom active (`panZoom="panZoomY"` / `"panZoomXY"`) the
328
+ baseline crept a few pixels per wheel notch — monotonically, in exactly the
329
+ gesture whose purpose is to hold it still. It was invisible without a
330
+ plot-level y zoom, where the two spaces coincide. Found by a post-merge
331
+ adversarial review of #676.
332
+ - `@pond-ts/charts`: **a `BarChart`'s baseline no longer relocates under a y
333
+ gutter pan/zoom.** `resolveBarBaseline` decided whether bars rest on zero or
334
+ on the axis floor by reading the axis's _live, transformed_ domain — the
335
+ same read a pan/zoom keeps moving — so once a gesture scrolled zero out of
336
+ view, every bar's rendered top silently snapped to the moving floor instead
337
+ of its actual value. It now reads the axis's declared (pre-transform)
338
+ domain for that decision, so a pan/zoom can move the viewport without ever
339
+ relocating a bar's own baseline.
340
+ - `@pond-ts/charts`: **[F-charts-21] a trading-time axis's stacked date-band
341
+ row no longer draws a second, colliding label over a collapsed-gap seam.**
342
+ `scale.bands()` picked its anchors from the raw calendar (a month or day
343
+ start) without checking whether that instant was live, so a period
344
+ beginning on a collapsed weekend or holiday clamped its label onto the same
345
+ seam a tick (or another band) already occupied — rendering as two
346
+ overlapping labels (`"Aug"` over `"Aug 3"`, or worse at tight zoom, fully
347
+ interleaved). Bands whose raw start needed clamping now dedupe against a
348
+ run of other clamped starts landing on the same live instant (picking the
349
+ genuinely-live member as the representative when one exists) and against
350
+ an already-labelled tick, so a seam carries exactly one label.
351
+
69
352
  ## [0.63.0] — 2026-08-18
70
353
 
71
354
  ### Added
@@ -722,6 +722,31 @@ export declare class TimeSeries<S extends SeriesSchema> {
722
722
  * bucket containing their timestamp. Interval-like events contribute to every bucket they
723
723
  * overlap under half-open overlap rules.
724
724
  *
725
+ * The grid **covers** `range` rather than sampling it: the first bucket
726
+ * emitted is the one *containing* `range.begin()`, even when it starts
727
+ * before it, so every event in `range` lands in some emitted bucket. This is
728
+ * what distinguishes bucketing from `align(...)` / `materialize(...)`, whose
729
+ * grids are sampled — there the bucket's sample point becomes the output
730
+ * key, so a bucket starting before the range would key a point outside the
731
+ * window the caller asked for.
732
+ *
733
+ * `range` bounds the **grid, not the event scan**. Every bucket is filled
734
+ * from every event it contains, so a leading bucket that starts before
735
+ * `range.begin()` also sums the events in front of it when the series has
736
+ * them. That is what makes a bucket's value independent of the window it is
737
+ * viewed through — the same bucket reads the same under any `range` — but it
738
+ * means a narrow `range` over a wide series does *not* clip contributions to
739
+ * the window. To bound the events as well, narrow the series (`within(...)`)
740
+ * rather than the grid; note that this instead leaves the edge buckets
741
+ * partially filled, which is the trade being made either way.
742
+ *
743
+ * Coverage applies to a grid this call realizes. A pre-realized
744
+ * `BoundedSequence` is an explicit bucket list and is used exactly as given
745
+ * — pond will not extend it with a bucket the caller did not ask for — so
746
+ * `aggregate(seq.bounded(r), ...)` keeps whatever leading edge `bounded`
747
+ * produced. Pass the `Sequence` itself with `{ range }` to get the covering
748
+ * grid.
749
+ *
725
750
  * Defaults:
726
751
  * - `range`: `series.timeRange()`
727
752
  *
@@ -273,10 +273,10 @@ function makeSmoothSchema(schema, target, output) {
273
273
  { name: output, kind: 'number', required: false },
274
274
  ]);
275
275
  }
276
- function toBoundedSequence(sequence, range, sample) {
276
+ function toBoundedSequence(sequence, range, sample, coverage = 'sample') {
277
277
  return sequence instanceof BoundedSequence
278
278
  ? sequence
279
- : sequence.bounded(range, { sample });
279
+ : sequence.bounded(range, { sample, coverage });
280
280
  }
281
281
  function isTimeKeyed(series) {
282
282
  return series.firstColumnKind === 'time';
@@ -3967,7 +3967,15 @@ function aggregateInternal(series, sequence, mapping, options = {}) {
3967
3967
  rows: [],
3968
3968
  });
3969
3969
  }
3970
- const buckets = toBoundedSequence(sequence, range, 'begin').intervals();
3970
+ // Bucketing selects by extent, not by sample point: every event inside
3971
+ // `range` must land in an emitted bucket, including the events between
3972
+ // `range.begin()` and the first grid boundary at or after it. Alignment
3973
+ // and materialization deliberately keep the default 'sample' coverage —
3974
+ // there the sample point becomes the output key, so a bucket starting
3975
+ // before the range would key a point outside the range the caller asked
3976
+ // for. Reported by Tidal in pond-ts#672
3977
+ // (docs/notes/tidal-aggregate-leading-bucket-2026-08.md).
3978
+ const buckets = toBoundedSequence(sequence, range, 'begin', 'overlap').intervals();
3971
3979
  const columns = aggregateColumns;
3972
3980
  if (isTimeKeyed(series)) {
3973
3981
  // Step 3B columnar fast path: when every mapped column is a built-in
package/dist/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export type { CalendarOptions, CalendarUnit, TimeZoneOptions, } from './core/cal
38
38
  export type { EventKey, IntervalInput, IntervalValue, TemporalLike, TimeRangeInput, TimestampInput, } from './core/temporal.js';
39
39
  export type { DurationInput } from './core/duration.js';
40
40
  export type { SequenceSample } from './sequence/sequence.js';
41
+ export type { SequenceCoverage } from './sequence/sequence.js';
41
42
  export type { LiveSeriesOptions, OrderingMode, RetentionPolicy, } from './live/live-series.js';
42
43
  export type { LiveRollingOptions, RollingWindow, } from './live/live-rolling-aggregation.js';
43
44
  export type { DurationString, FusedMapping, FusedMappingElaborated, FusedMappingValue, FusedRollingSchema, FusedPartitionedRollingSchema, } from './schema/index.js';
@@ -1,6 +1,7 @@
1
1
  export { Sequence } from './sequence.js';
2
2
  export { BoundedSequence } from './bounded-sequence.js';
3
3
  export type { SequenceSample } from './sequence.js';
4
+ export type { SequenceCoverage } from './sequence.js';
4
5
  export type { SampleStrategy } from './sample.js';
5
6
  export type { BatchSampleStrategy } from './sample.js';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -4,6 +4,21 @@ import type { DurationInput } from '../core/duration.js';
4
4
  import type { TemporalLike, TimestampInput } from '../core/temporal.js';
5
5
  export type { DurationInput };
6
6
  export type SequenceSample = 'begin' | 'center' | 'end';
7
+ /**
8
+ * How `bounded(...)` decides which buckets a range selects.
9
+ *
10
+ * - `'sample'` (default) — by the bucket's **sample point**: a bucket is
11
+ * selected when the instant picked by `sample` falls in the range. This is
12
+ * what alignment wants, where the sample point becomes the output key.
13
+ * - `'overlap'` — by the bucket's **extent**: a bucket is selected when any
14
+ * part of it overlaps the range, so the bucket *containing* `range.begin()`
15
+ * is included even though it starts before it. This is what bucketing wants,
16
+ * where every event in the range must land in some emitted bucket.
17
+ *
18
+ * `'overlap'` selects on extent alone, so `sample` does not affect which
19
+ * buckets it returns.
20
+ */
21
+ export type SequenceCoverage = 'sample' | 'overlap';
7
22
  type FixedSequenceInput = {
8
23
  every: DurationInput;
9
24
  anchor?: TimestampInput;
@@ -69,7 +84,31 @@ export declare class Sequence {
69
84
  * Example: `sequence.bounded(new TimeRange({ start, end }))`.
70
85
  * Realizes a finite `BoundedSequence` over the supplied range.
71
86
  *
72
- * Sample position controls which intervals are selected:
87
+ * `coverage` chooses what "over the range" means, and is the more
88
+ * consequential of the two options:
89
+ *
90
+ * - `'sample'` (default) — select by sample point, per the table below. The
91
+ * bucket containing `range.begin()` is **excluded** when it starts before
92
+ * it, because its sample point sits outside the range.
93
+ * - `'overlap'` — select every bucket whose extent overlaps the range,
94
+ * including the one containing `range.begin()`. `sample` is then ignored:
95
+ * selection is on extent, not on a point. Use this whenever the buckets
96
+ * are containers that must account for every instant in the range —
97
+ * `aggregate` realizes its grid this way.
98
+ *
99
+ * At the default `sample: 'begin'` only the leading edge differs between the
100
+ * two: both keep a trailing bucket that starts inside the range and runs
101
+ * past its end. Against a non-default `sample` the trailing edge moves too,
102
+ * because `'overlap'` drops the sample offset that `'sample'` shifts both
103
+ * edges by — another reason to read `'overlap'` as ignoring `sample`
104
+ * outright rather than as a modifier on it.
105
+ *
106
+ * A single-instant range under `'overlap'` is the flooring primitive:
107
+ * `sequence.bounded({ start: t, end: t }, { coverage: 'overlap' })` returns
108
+ * exactly the bucket containing `t`.
109
+ *
110
+ * Sample position controls which intervals are selected under
111
+ * `coverage: 'sample'`:
73
112
  *
74
113
  * - `'begin'` (default) — sample point is the interval's start.
75
114
  * Includes buckets where `sample ∈ [range.begin, range.end]`.
@@ -84,6 +123,7 @@ export declare class Sequence {
84
123
  */
85
124
  bounded(range: TemporalLike, options?: {
86
125
  sample?: SequenceSample;
126
+ coverage?: SequenceCoverage;
87
127
  }): BoundedSequence;
88
128
  }
89
129
  //# sourceMappingURL=sequence.d.ts.map
@@ -99,7 +99,31 @@ export class Sequence {
99
99
  * Example: `sequence.bounded(new TimeRange({ start, end }))`.
100
100
  * Realizes a finite `BoundedSequence` over the supplied range.
101
101
  *
102
- * Sample position controls which intervals are selected:
102
+ * `coverage` chooses what "over the range" means, and is the more
103
+ * consequential of the two options:
104
+ *
105
+ * - `'sample'` (default) — select by sample point, per the table below. The
106
+ * bucket containing `range.begin()` is **excluded** when it starts before
107
+ * it, because its sample point sits outside the range.
108
+ * - `'overlap'` — select every bucket whose extent overlaps the range,
109
+ * including the one containing `range.begin()`. `sample` is then ignored:
110
+ * selection is on extent, not on a point. Use this whenever the buckets
111
+ * are containers that must account for every instant in the range —
112
+ * `aggregate` realizes its grid this way.
113
+ *
114
+ * At the default `sample: 'begin'` only the leading edge differs between the
115
+ * two: both keep a trailing bucket that starts inside the range and runs
116
+ * past its end. Against a non-default `sample` the trailing edge moves too,
117
+ * because `'overlap'` drops the sample offset that `'sample'` shifts both
118
+ * edges by — another reason to read `'overlap'` as ignoring `sample`
119
+ * outright rather than as a modifier on it.
120
+ *
121
+ * A single-instant range under `'overlap'` is the flooring primitive:
122
+ * `sequence.bounded({ start: t, end: t }, { coverage: 'overlap' })` returns
123
+ * exactly the bucket containing `t`.
124
+ *
125
+ * Sample position controls which intervals are selected under
126
+ * `coverage: 'sample'`:
103
127
  *
104
128
  * - `'begin'` (default) — sample point is the interval's start.
105
129
  * Includes buckets where `sample ∈ [range.begin, range.end]`.
@@ -114,6 +138,7 @@ export class Sequence {
114
138
  */
115
139
  bounded(range, options = {}) {
116
140
  const sample = options.sample ?? 'begin';
141
+ const coverage = options.coverage ?? 'sample';
117
142
  const requested = toTimeRange(range);
118
143
  const intervals = [];
119
144
  if (this.#kind === 'fixed') {
@@ -129,11 +154,26 @@ export class Sequence {
129
154
  // boundary case symmetric: begin-sampling at range.end() and
130
155
  // end-sampling at range.begin() would otherwise BOTH include
131
156
  // intervals whose extent sits entirely outside the range.
132
- const sampleOffset = sample === 'center' ? stepMs / 2 : sample === 'end' ? stepMs : 0;
133
- const firstIndex = sample === 'end'
134
- ? Math.floor((requested.begin() - sampleOffset - anchorMs) / stepMs) +
135
- 1
136
- : Math.ceil((requested.begin() - sampleOffset - anchorMs) / stepMs);
157
+ //
158
+ // Under `coverage: 'overlap'` selection is on extent instead, so the
159
+ // sample point plays no part: a bucket is in when its extent overlaps
160
+ // the range. `end > requested.begin()` reduces to `floor` on the same
161
+ // quotient `ceil` is taken of above the one index of difference is
162
+ // the bucket containing `requested.begin()`. The trailing test is
163
+ // `begin <= requested.end()`, which is what `lastIndex` already
164
+ // computes at a zero offset, so only the leading edge moves.
165
+ const sampleOffset = coverage === 'overlap'
166
+ ? 0
167
+ : sample === 'center'
168
+ ? stepMs / 2
169
+ : sample === 'end'
170
+ ? stepMs
171
+ : 0;
172
+ const firstIndex = coverage === 'overlap'
173
+ ? Math.floor((requested.begin() - anchorMs) / stepMs)
174
+ : sample === 'end'
175
+ ? Math.floor((requested.begin() - sampleOffset - anchorMs) / stepMs) + 1
176
+ : Math.ceil((requested.begin() - sampleOffset - anchorMs) / stepMs);
137
177
  const lastIndex = Math.floor((requested.end() - sampleOffset - anchorMs) / stepMs);
138
178
  for (let index = firstIndex; index <= lastIndex; index += 1) {
139
179
  const start = anchorMs + index * stepMs;
@@ -156,14 +196,25 @@ export class Sequence {
156
196
  : sample === 'center'
157
197
  ? start + (end - start) / 2
158
198
  : start;
159
- if (sampleTime > requested.end()) {
199
+ // Under `coverage: 'overlap'` selection is on extent, so both tests
200
+ // read the bucket's own start rather than its sample point.
201
+ const selector = coverage === 'overlap' ? start : sampleTime;
202
+ if (selector > requested.end()) {
160
203
  break;
161
204
  }
162
205
  // 'begin' and 'center': sample ∈ [requested.begin, requested.end]
163
206
  // 'end': sample ∈ (requested.begin, requested.end]
164
- const include = sample === 'end'
165
- ? sampleTime > requested.begin()
166
- : sampleTime >= requested.begin();
207
+ //
208
+ // Under 'overlap' every candidate is in: the walk starts at the bucket
209
+ // containing `requested.begin()` (`toPlainDateStart` floors to it), and
210
+ // every later bucket starts after that, so the leading test can only
211
+ // ever pass. This is the one line the leading-bucket drop turned on —
212
+ // the containing bucket was computed above and then discarded here.
213
+ const include = coverage === 'overlap'
214
+ ? true
215
+ : sample === 'end'
216
+ ? sampleTime > requested.begin()
217
+ : sampleTime >= requested.begin();
167
218
  if (include) {
168
219
  intervals.push(new Interval({ value: start, start, end }));
169
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {