pond-ts 0.44.1 → 0.46.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/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.46.0...HEAD
12
+ [0.46.0]: https://github.com/pjm17971/pond-ts/compare/v0.45.0...v0.46.0
13
+ [0.45.0]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...v0.45.0
12
14
  [0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
13
15
  [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
14
16
  [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
@@ -43,6 +45,146 @@ and type-level changes; patch bumps are strictly additive.
43
45
 
44
46
  ## [Unreleased]
45
47
 
48
+ ## [0.46.0] — 2026-07-14
49
+
50
+ ### Changed
51
+
52
+ - **charts:** the boundary (second-row) axis label's **context** now pins to
53
+ the plot's left edge instead of riding the first tick: it shows the period
54
+ the *domain start* is in, and a crossing label sliding toward the edge
55
+ pushes it off (the sticky-header behavior). On a live sliding window the
56
+ old first-tick anchoring made `Jan 01` hop tick-to-tick as ticks scrolled
57
+ out; pinned, it stays put until the period actually changes. Crossing
58
+ labels (day/year turns) still ride their ticks — including a first tick
59
+ whose period differs from the domain start's. `TradingTimeScale` gains
60
+ `boundaryContext(count)`; `tickBoundaries` now labels crossings only.
61
+
62
+ ### Fixed
63
+
64
+ - **charts:** a live (sliding-window) time axis no longer flickers between two
65
+ tick grains: the clock-rung choice now derives from the window's live span
66
+ (constant while sliding) instead of the enumerated anchor count, which
67
+ oscillates ±1 with the window's phase and flipped the grain for single
68
+ frames whenever it sat exactly at the width-derived cap.
69
+
70
+ ### Added
71
+
72
+ - **charts:** **dual x-axes** — two tick layouts on one shared scale. A second
73
+ `<XAxis>` stacks by declaration order (above/below the plot, either side,
74
+ same side twice); the new **`transform`** prop (`{ to, from }`, exported
75
+ `AxisTransform`) relabels an axis into a derived unit: strike ↔ moneyness on
76
+ a top axis, or a nonlinear BS-delta strip under a std-moneyness chart. Ticks
77
+ are nice derived-unit values chosen by a pixel-aware multi-resolution fill
78
+ (1-2-5 steps, coarsest first, admitted where they keep room), so a span the
79
+ transform compresses gets coarse ticks and a stretched span picks up finer
80
+ ones — and a label-honesty filter drops any tick whose formatted label would
81
+ lie about its position. Gridlines stay on the container's primary ticks; the
82
+ cursor pill on a transformed axis reads in the derived unit. Stories under
83
+ `Charts/Axes/DualX`. Each `<XAxis>` **and `<YAxis>`** also takes a
84
+ per-instance **`color`** (labels, tick marks, rule, title) — the lever that
85
+ distinguishes stacked x strips (a blue delta strip under a grey primary) and
86
+ colours a y axis to match its series (the dual-axis convention).
87
+
88
+ ### Fixed
89
+
90
+ - **charts:** annotation label chips now clip to the plot: a marker whose pole
91
+ pans off-plot no longer leaves its chip floating in the axis gutter, and a
92
+ partially visible region's chip clamps to the plot's left edge (culled only
93
+ when the region is entirely out of view). The lines/fills were already
94
+ SVG-clipped — only the DOM chips escaped.
95
+
96
+ ## [0.45.0] — 2026-07-14
97
+
98
+ ### Added
99
+
100
+ - **charts:** the time axis now walks a **logical tick ladder** — clock/calendar
101
+ units (1s…30s, 1m…30m, 1H…12H, day / week / month / quarter / year), picking
102
+ the finest grain that fits the width-derived cap — and renders **two-tier labels**:
103
+ a first row at the tick grain (`14:00`, `Feb 02`, `Feb`, `2026`) plus a
104
+ **boundary row** carrying the coarser context the first row omits (the date
105
+ under clock ticks, the year under day/week/month ticks — never a unit the
106
+ first row already shows), shown once under
107
+ the first tick of each new period. Hour anchors are generated in **live**
108
+ trading time, so none lands in a collapsed gap, an early close, or a lunch
109
+ break. `TradingTimeScale` gains `tickBoundaries(count)`; a cramped leading
110
+ partial-period anchor (the `"Jun 23Jul 07"` pile-up) is dropped. Systematic
111
+ story matrix under `Charts/TimeAxisTicks`.
112
+ - **financial:** the rest of the first study batch — `rollingStdev`,
113
+ `rollingMin`, `rollingMax`, `rollingPercentile` (linear interpolation),
114
+ `zScore` (rolling), `envelope` (MA ± percent, `maType` sma/ema), and
115
+ `percentChange` (n-bar rate of change). Same shape as the others — `column` /
116
+ `output` (or `prefix`) on every one, bar-count periods, length-preserving
117
+ warm-up — and each has a fluent method (`bars.zScore({ period: 20 })`) and a
118
+ **pandas oracle** case (values cross-validated bar-for-bar). This completes the
119
+ #449 first batch.
120
+ - **financial:** opt-in **fluent studies** via `import '@pond-ts/financial/fluent'`
121
+ — mounts `sma` / `ema` / `bollinger` as chainable `TimeSeries` methods so
122
+ composition reads like the core operators beside it:
123
+ `bars.sma({ period: 20 }).ema({ period: 12 }).bollinger({ period: 20 })`.
124
+ Opt-in by import (the default entry leaves `TimeSeries` untouched, so a
125
+ non-financial series never sees `.sma()`); the methods are exactly the
126
+ standalone functions bound to `this`, fully typed (each appends its column to
127
+ the schema). Same prototype-augmentation pattern core uses for column methods.
128
+ - **financial:** `@pond-ts/financial` gains its first **studies** — `sma`, `ema`,
129
+ and `bollinger` (Bollinger Bands®) — pure functions that append a column (or a
130
+ `${prefix}Middle/Upper/Lower` family) to a bar series. Every study takes a
131
+ `column` source (default `'close'`) and an `output` name, so a study runs over
132
+ any numeric column including another study's output; periods are **bar counts**
133
+ (on core's count window, gap-correct); warm-up rows are `undefined`
134
+ (length-preserving). Plus the `OhlcvColumns` contract + `DEFAULT_OHLCV`. (Core
135
+ `AppendColumn` is now exported so study return types name their appended
136
+ column.)
137
+ - **core:** `smooth(col, 'ema', …)` gains the financial **`span`** rate
138
+ convention (`α = 2/(span+1)`, e.g. `{ span: 12 }` for a 12-period EMA) as an
139
+ alternative to `alpha` (exactly one required), and a length-preserving
140
+ **`minSamples`** warm-up that emits `undefined` for the first `N` present
141
+ values while **keeping the row count** — mirroring `rolling`'s `minSamples`,
142
+ the one warm-up convention studies use so a smoothed line aligns on its
143
+ source's time axis. (The existing `warmup` option is unchanged — it still
144
+ *drops* the head rows; `minSamples` is the length-preserving counterpart.)
145
+ - **core:** `TimeSeries.rolling` accepts a **count-based** window —
146
+ `rolling({ count: N }, mapping, opts?)` reduces the last / next / centered
147
+ `N` _rows_ (bars) by position instead of a time span. Unlike a duration
148
+ window, an N-bar window stays correct across session gaps (weekends,
149
+ overnight) where `N` bars do not equal `N × barSize` of time — the load-
150
+ bearing primitive for financial N-bar studies (SMA-20, Bollinger-20, rolling
151
+ stdev). Honours `alignment` and `minSamples` exactly like a duration window
152
+ (`minSamples: N` gives the conventional first-`N-1`-rows-`undefined` warmup);
153
+ per-row output only (not supported with a sequence). Same amortized-O(1)-per-
154
+ row sweep as the duration path.
155
+ - **charts:** `<LineChart sessionBreaks>` breaks the line at each trading-axis
156
+ discontinuity (a session / day / lunch close→open) instead of connecting the
157
+ prior close straight to the next open across the collapsed gap. For intraday
158
+ data on a `discontinuities` / `calendar` axis this gives the terminal look —
159
+ the line ends at the close and re-starts at the open, so one session's price
160
+ doesn't visually flow into the next. It's a **scale** break (driven by the
161
+ axis's collapsed gaps), orthogonal to `gaps` (a **data** break, a NaN run) —
162
+ set both independently. Default `false`; a no-op on a continuous axis or a
163
+ provider without `boundaries`. New `Charts/TradingTimeAxis/SessionBreaks`
164
+ story (connected vs broken) + `sessionRuns` / `gappingTicks` helpers.
165
+
166
+ ### Changed
167
+
168
+ - **charts:** a **plain continuous time axis** now runs the same ladder as a
169
+ trading-calendar axis (via an internal gap-free identity provider) instead of
170
+ d3's multi-scale default — so a year of daily data ticks cleanly on month
171
+ starts (`Jul Aug … Jun` + the year underneath) rather than mixed
172
+ `"Jun 23" / "Sep" / "Dec"` labels, and its tick count is now width-derived
173
+ like the trading axis's. Month/quarter-grain anchor labels are now bare
174
+ months (`Feb`, was `Feb 02`) with the year on the boundary row;
175
+ `coarsenCalendar`'s finest granularity is renamed `'session'` → `'day'`.
176
+
177
+ ### Fixed
178
+
179
+ - **core:** `TimeSeries.withColumn` now appends an **optional** column
180
+ (`required: false`) rather than a required one. It already accepted
181
+ `(number | undefined)[]`, but marking the column required meant a later
182
+ strict-intake rebuild (`smooth`, any row reconstruction) threw on the gaps —
183
+ so a column with a warm-up (a rolling study fed into an EMA, e.g.
184
+ `ema(sma(bars, …), …)`) crashed. The appended column's type is now
185
+ `number | undefined`, matching `smooth`'s optional append. (`OptionalNumberColumn`
186
+ is exported for the widened return type.)
187
+
46
188
  ## [0.44.1] — 2026-07-13
47
189
 
48
190
  A `@pond-ts/charts` patch: fixes trading-time axis tick density. `pond-ts`,
@@ -1,4 +1,4 @@
1
- import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, AppendColumn, CollapseSchema, EventDataForSchema, EventForSchema, FirstColKind, IntervalKeyedSchema, JsonRowFormat, JoinManySchema, JoinSchema, JoinType, NumericColumnNameForSchema, NormalizedObjectRow, NormalizedRowForSchema, PivotByGroupSchema, PointRowForSchema, PrefixedJoinManySchema, PrefixedJoinSchema, ReduceResult, RenameMap, ValidatedAggregateMap } from '../schema/index.js';
1
+ import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, AppendColumn, OptionalNumberColumn, CollapseSchema, EventDataForSchema, EventForSchema, FirstColKind, IntervalKeyedSchema, JsonRowFormat, JoinManySchema, JoinSchema, JoinType, NumericColumnNameForSchema, NormalizedObjectRow, NormalizedRowForSchema, PivotByGroupSchema, PointRowForSchema, PrefixedJoinManySchema, PrefixedJoinSchema, ReduceResult, RenameMap, ValidatedAggregateMap } from '../schema/index.js';
2
2
  import type { RenameSchema, RollingAlignment, RollingSchema, ColumnValue, DedupeKeep, DiffSchema, FillMapping, FillStrategy, ScalarKind, ScalarValue, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectSchema, SeriesSchema, TimeKeyedSchema, TimeSeriesJsonInput, TimeSeriesInput, TimeRangeKeyedSchema, ValueColumnKindForName, ValueColumnNameForSchema, ValueColumnsForSchema, ValueKeyedSchema } from '../schema/index.js';
3
3
  import { type ScanStep } from './operators/scan.js';
4
4
  import { ValueSeries } from './value-series.js';
@@ -1076,10 +1076,22 @@ export declare class TimeSeries<S extends SeriesSchema> {
1076
1076
  * Computes sequence-driven rolling aggregations and returns an interval-keyed series on the
1077
1077
  * supplied grid.
1078
1078
  *
1079
+ * Example: `series.rolling({ count: 20 }, { close: "avg" }, { minSamples: 20 })`.
1080
+ * A **count-based** window of the last `N` *rows* (bars), keyed on row
1081
+ * position not time. `{ count: N }` in place of a duration reduces, for each
1082
+ * row, the `N` rows in its index window — so an "N-bar" study (SMA-20,
1083
+ * Bollinger-20, rolling stdev) stays correct across session gaps (weekends,
1084
+ * overnight) where a duration window silently spans the wrong number of bars.
1085
+ * Honours `alignment` and `minSamples` exactly like a duration window
1086
+ * (`minSamples: N` gives the conventional warmup — the first `N-1` rows emit
1087
+ * `undefined`). Per-row output only; not supported with a sequence.
1088
+ *
1079
1089
  * Rolling windows are anchored either at each event's `begin()` time or at the sample point of
1080
- * each sequence bucket. Membership is determined from source event `begin()` times.
1090
+ * each sequence bucket. Membership is determined from source event `begin()` times (a duration
1091
+ * window) or from row position (a `{ count }` window).
1081
1092
  *
1082
- * Supported alignments:
1093
+ * Supported alignments (a `{ count: N }` window applies the same shape in row-index space, with
1094
+ * a centered window biased one row toward `leading` when `N` is even):
1083
1095
  * - `"trailing"`: `(t - window, t]`
1084
1096
  * - `"leading"`: `[t, t + window)`
1085
1097
  * - `"centered"`: `[t - window/2, t + window/2)`
@@ -1114,7 +1126,9 @@ export declare class TimeSeries<S extends SeriesSchema> {
1114
1126
  * therefore read as distinct values here. Scalar columns
1115
1127
  * (number / string / boolean) are unaffected (value semantics).
1116
1128
  */
1117
- rolling<const Mapping extends ValidatedAggregateMap<S, Mapping>>(window: DurationInput, mapping: Mapping, options?: {
1129
+ rolling<const Mapping extends ValidatedAggregateMap<S, Mapping>>(window: DurationInput | {
1130
+ count: number;
1131
+ }, mapping: Mapping, options?: {
1118
1132
  alignment?: RollingAlignment;
1119
1133
  minSamples?: number;
1120
1134
  }): TimeSeries<RollingSchema<S, Mapping>>;
@@ -1129,6 +1143,16 @@ export declare class TimeSeries<S extends SeriesSchema> {
1129
1143
  * Applies a smoothing transform to one numeric payload column while preserving the original key
1130
1144
  * type, key values, and all non-target payload fields.
1131
1145
  *
1146
+ * **EMA rate + warm-up.** Give the rate as either `alpha` (0,1] **or** the
1147
+ * financial `span` convention (`α = 2/(span+1)` — a `span`-period EMA, e.g.
1148
+ * `{ span: 12 }`), exactly one of the two. Two warm-up options, both optional:
1149
+ * `warmup: N` **drops** the first N rows (length-changing — hides the noisy
1150
+ * initial convergence); `minSamples: N` **keeps** the rows but emits
1151
+ * `undefined` until N present values have been consumed (length-preserving —
1152
+ * mirrors {@link TimeSeries.rolling}'s `minSamples`, the convention studies
1153
+ * use so a smoothed line aligns on its source's time axis). The EMA converges
1154
+ * on the real values underneath either way.
1155
+ *
1132
1156
  * Example: `series.smooth("value", "movingAverage", { window: "5m", alignment: "centered", output: "valueAvg" })`.
1133
1157
  * Computes a moving average over the selected numeric column using anchor points derived from
1134
1158
  * event keys. `Time` keys use their timestamp. `TimeRange` and `Interval` keys use the midpoint
@@ -1162,6 +1186,12 @@ export declare class TimeSeries<S extends SeriesSchema> {
1162
1186
  smooth<const Target extends NumericColumnNameForSchema<S>, const Output extends string | undefined = undefined>(column: Target, method: SmoothMethod, options: {
1163
1187
  alpha: number;
1164
1188
  warmup?: number;
1189
+ minSamples?: number;
1190
+ output?: Output;
1191
+ } | {
1192
+ span: number;
1193
+ warmup?: number;
1194
+ minSamples?: number;
1165
1195
  output?: Output;
1166
1196
  } | {
1167
1197
  window: DurationInput;
@@ -1336,7 +1366,7 @@ export declare class TimeSeries<S extends SeriesSchema> {
1336
1366
  * (`fromTrustedColumns`, skipping the finite scan) is a deferred sibling for
1337
1367
  * when a perf-critical consumer earns it.
1338
1368
  */
1339
- withColumn<const Name extends string>(name: Name, values: ReadonlyArray<number | undefined> | Float64Array): TimeSeries<AppendColumn<S, Name, 'number'>>;
1369
+ withColumn<const Name extends string>(name: Name, values: ReadonlyArray<number | undefined> | Float64Array): TimeSeries<readonly [S[0], ...ValueColumnsForSchema<S>, OptionalNumberColumn<Name>]>;
1340
1370
  /** Example: `series.collapse(["in", "out"], "avg", fn)`. Collapses selected payload fields into a single derived field across each event in the series. */
1341
1371
  collapse<const Keys extends readonly (keyof EventDataForSchema<S>)[], Name extends string, R extends ScalarValue>(keys: Keys, output: Name, reducer: (values: Pick<EventDataForSchema<S>, Keys[number]>) => R): TimeSeries<CollapseSchema<S, Keys[number] & string, Name, R>>;
1342
1372
  collapse<const Keys extends readonly (keyof EventDataForSchema<S>)[], Name extends string, R extends ScalarValue>(keys: Keys, output: Name, reducer: (values: Pick<EventDataForSchema<S>, Keys[number]>) => R, options: {
@@ -2114,7 +2114,21 @@ export class TimeSeries {
2114
2114
  kind: spec.kind,
2115
2115
  required: false,
2116
2116
  }));
2117
- const windowMs = parseDuration(window);
2117
+ // A `{ count: N }` window reduces the last/next/centered `N` rows by index
2118
+ // rather than a time span — the count-based (N-bar) window. Detected here so
2119
+ // the duration is never parsed for it.
2120
+ const countWindow = typeof window === 'object' && window !== null && 'count' in window
2121
+ ? window.count
2122
+ : null;
2123
+ if (countWindow !== null) {
2124
+ if (!Number.isInteger(countWindow) || countWindow < 1) {
2125
+ throw new TypeError('rolling { count } must be a positive integer (row count)');
2126
+ }
2127
+ if (sequence) {
2128
+ throw new TypeError('rolling count windows ({ count }) are not supported with a sequence');
2129
+ }
2130
+ }
2131
+ const windowMs = countWindow === null ? parseDuration(window) : 0;
2118
2132
  const alignment = options.alignment ?? 'trailing';
2119
2133
  // Default 0 disables the gate — preserves prior behavior where
2120
2134
  // empty windows still invoke the reducer (custom reducers may
@@ -2229,7 +2243,46 @@ export class TimeSeries {
2229
2243
  }
2230
2244
  }
2231
2245
  };
2232
- if (alignment === 'trailing') {
2246
+ if (countWindow !== null) {
2247
+ // Count-based window: for each row, the `N` rows in its index window (not
2248
+ // a time span). `lo`/`hi` are both monotonic non-decreasing in the row
2249
+ // index for every alignment, so the same reducer add/remove sweep stays
2250
+ // amortized O(1) per row — each row enters and leaves the window once.
2251
+ // Rows are the unit (no equal-timestamp grouping — `N` counts bars), and a
2252
+ // centered even `N` biases one row toward the leading side.
2253
+ const n = countWindow;
2254
+ const leftSpan = Math.floor((n - 1) / 2);
2255
+ const rightSpan = n - 1 - leftSpan;
2256
+ for (let index = 0; index < rowCount; index++) {
2257
+ let lo;
2258
+ let hi;
2259
+ if (alignment === 'trailing') {
2260
+ lo = index - n + 1 < 0 ? 0 : index - n + 1;
2261
+ hi = index;
2262
+ }
2263
+ else if (alignment === 'leading') {
2264
+ lo = index;
2265
+ hi = index + n - 1 < rowCount ? index + n - 1 : rowCount - 1;
2266
+ }
2267
+ else {
2268
+ lo = index - leftSpan < 0 ? 0 : index - leftSpan;
2269
+ hi = index + rightSpan < rowCount ? index + rightSpan : rowCount - 1;
2270
+ }
2271
+ while (windowEnd <= hi) {
2272
+ addEvent(windowEnd);
2273
+ windowEnd += 1;
2274
+ }
2275
+ while (windowStart < lo) {
2276
+ removeEvent(windowStart);
2277
+ windowStart += 1;
2278
+ }
2279
+ const aggregated = snapshotWindow();
2280
+ for (let c = 0; c < outValues.length; c++) {
2281
+ outValues[c][index] = aggregated[c];
2282
+ }
2283
+ }
2284
+ }
2285
+ else if (alignment === 'trailing') {
2233
2286
  for (let groupStart = 0; groupStart < rowCount;) {
2234
2287
  const anchor = beginTimes[groupStart];
2235
2288
  let groupEnd = groupStart + 1;
@@ -2323,6 +2376,16 @@ export class TimeSeries {
2323
2376
  * Applies a smoothing transform to one numeric payload column while preserving the original key
2324
2377
  * type, key values, and all non-target payload fields.
2325
2378
  *
2379
+ * **EMA rate + warm-up.** Give the rate as either `alpha` (0,1] **or** the
2380
+ * financial `span` convention (`α = 2/(span+1)` — a `span`-period EMA, e.g.
2381
+ * `{ span: 12 }`), exactly one of the two. Two warm-up options, both optional:
2382
+ * `warmup: N` **drops** the first N rows (length-changing — hides the noisy
2383
+ * initial convergence); `minSamples: N` **keeps** the rows but emits
2384
+ * `undefined` until N present values have been consumed (length-preserving —
2385
+ * mirrors {@link TimeSeries.rolling}'s `minSamples`, the convention studies
2386
+ * use so a smoothed line aligns on its source's time axis). The EMA converges
2387
+ * on the real values underneath either way.
2388
+ *
2326
2389
  * Example: `series.smooth("value", "movingAverage", { window: "5m", alignment: "centered", output: "valueAvg" })`.
2327
2390
  * Computes a moving average over the selected numeric column using anchor points derived from
2328
2391
  * event keys. `Time` keys use their timestamp. `TimeRange` and `Interval` keys use the midpoint
@@ -2364,27 +2427,56 @@ export class TimeSeries {
2364
2427
  return typeof raw === 'number' ? raw : undefined;
2365
2428
  });
2366
2429
  if (method === 'ema') {
2367
- if (!('alpha' in options)) {
2368
- throw new TypeError('ema smoothing requires an alpha option');
2430
+ // Rate parameter: `alpha` directly, or the financial `span` convention
2431
+ // = 2/(span+1) a `span`-period EMA), exactly one of the two.
2432
+ const hasAlpha = 'alpha' in options && options.alpha !== undefined;
2433
+ const hasSpan = 'span' in options && options.span !== undefined;
2434
+ if (hasAlpha === hasSpan) {
2435
+ throw new TypeError('ema smoothing requires exactly one of alpha or span');
2436
+ }
2437
+ let alpha;
2438
+ if (hasSpan) {
2439
+ const span = options.span;
2440
+ if (typeof span !== 'number' || !Number.isFinite(span) || span < 1) {
2441
+ throw new TypeError('ema smoothing requires span to be a finite number >= 1');
2442
+ }
2443
+ alpha = 2 / (span + 1);
2369
2444
  }
2370
- const alpha = options.alpha;
2371
- if (typeof alpha !== 'number' ||
2372
- !Number.isFinite(alpha) ||
2373
- alpha <= 0 ||
2374
- alpha > 1) {
2375
- throw new TypeError('ema smoothing requires alpha to be a finite number in the range (0, 1]');
2445
+ else {
2446
+ alpha = options.alpha;
2447
+ if (typeof alpha !== 'number' ||
2448
+ !Number.isFinite(alpha) ||
2449
+ alpha <= 0 ||
2450
+ alpha > 1) {
2451
+ throw new TypeError('ema smoothing requires alpha to be a finite number in the range (0, 1]');
2452
+ }
2376
2453
  }
2377
2454
  // Optional warm-up: drop the first N output rows to hide the
2378
2455
  // noisy initial convergence of the EMA. The smoother still
2379
2456
  // processes those events so `previous` is correctly warmed up
2380
- // by the time we keep a row.
2457
+ // by the time we keep a row. **Length-changing** (rows are removed) —
2458
+ // for the length-preserving warmup that studies want (align on a shared
2459
+ // time axis), use `minSamples` instead.
2381
2460
  const warmup = 'warmup' in options && options.warmup !== undefined
2382
2461
  ? options.warmup
2383
2462
  : 0;
2384
2463
  if (!Number.isInteger(warmup) || warmup < 0 || !Number.isFinite(warmup)) {
2385
2464
  throw new TypeError('ema smoothing requires warmup to be a non-negative integer');
2386
2465
  }
2466
+ // Length-preserving warm-up: emit `undefined` until `minSamples` present
2467
+ // source values have been consumed, keeping the row (mirrors `rolling`'s
2468
+ // `minSamples`). The EMA still converges on the real values underneath —
2469
+ // only the emitted value is masked — so the kept tail is unchanged.
2470
+ const minSamples = 'minSamples' in options && options.minSamples !== undefined
2471
+ ? options.minSamples
2472
+ : 0;
2473
+ if (!Number.isInteger(minSamples) ||
2474
+ minSamples < 0 ||
2475
+ !Number.isFinite(minSamples)) {
2476
+ throw new TypeError('ema smoothing requires minSamples to be a non-negative integer');
2477
+ }
2387
2478
  let previous;
2479
+ let seen = 0;
2388
2480
  const resultRows = this.events.map((event) => {
2389
2481
  const raw = event.get(column);
2390
2482
  const smoothed = typeof raw !== 'number'
@@ -2394,10 +2486,14 @@ export class TimeSeries {
2394
2486
  : alpha * raw + (1 - alpha) * previous;
2395
2487
  if (smoothed !== undefined) {
2396
2488
  previous = smoothed;
2489
+ seen += 1;
2397
2490
  }
2491
+ // Mask the emitted value during the length-preserving warm-up, but keep
2492
+ // `previous`/`seen` advancing on the real value above.
2493
+ const emitted = seen < minSamples ? undefined : smoothed;
2398
2494
  const nextEvent = output === undefined
2399
- ? event.set(column, smoothed)
2400
- : event.merge({ [output]: smoothed });
2495
+ ? event.set(column, emitted)
2496
+ : event.merge({ [output]: emitted });
2401
2497
  return Object.freeze([
2402
2498
  nextEvent.key(),
2403
2499
  ...resultSchema
@@ -2435,7 +2531,7 @@ export class TimeSeries {
2435
2531
  // `missing: 'skip'` — a cell whose own value is missing stays missing
2436
2532
  // (don't fit a fabricated value across the hole from its present
2437
2533
  // neighbours). Default `'bridge'` is the prior behaviour (fit everywhere).
2438
- const skipMissing = options.missing === 'skip';
2534
+ const skipMissing = 'missing' in options && options.missing === 'skip';
2439
2535
  const resultRows = this.events.map((event, index) => {
2440
2536
  const smoothed = skipMissing && sourceValues[index] === undefined
2441
2537
  ? undefined
@@ -2967,9 +3063,15 @@ export class TimeSeries {
2967
3063
  assertColumnValuesMatchKind('number', values, `withColumn '${String(name)}'`);
2968
3064
  const column = columnFromValuesByKind('number', values);
2969
3065
  const reshaped = withColumnAppended(this.#store.store, name, column);
3066
+ // The appended column is **optional** (`required: false`): `values` may
3067
+ // carry `undefined` (a missing cell), so the column can have gaps. Marking
3068
+ // it required would make a later strict-intake rebuild (`smooth`, a row
3069
+ // reconstruction) reject those gaps — breaking composition on a column with
3070
+ // a warm-up (e.g. a rolling study fed into an EMA). Matches `smooth`'s
3071
+ // `OptionalNumberColumn` append.
2970
3072
  const resultSchema = Object.freeze([
2971
3073
  ...this.schema,
2972
- { name, kind: 'number' },
3074
+ { name, kind: 'number', required: false },
2973
3075
  ]);
2974
3076
  return TimeSeries.#fromTrustedStore(this.name, resultSchema, reshaped);
2975
3077
  }
package/dist/index.d.ts CHANGED
@@ -30,7 +30,7 @@ export { ArrayColumn } from './columnar/array-column.js';
30
30
  export { ChunkedArrayColumn, ChunkedBooleanColumn, ChunkedFloat64Column, ChunkedStringColumn, } from './columnar/chunked-column.js';
31
31
  export { type IntervalLabelKind, type KeyColumn, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, ValueKeyColumn, } from './columnar/key-column.js';
32
32
  export { type ValidityBitmap } from './columnar/validity.js';
33
- export type { AlignSchema, ArrayColumnNameForSchema, BaselineSchema, AggregateFunction, AggregateReducer, AggregateOutputMap, AggregateOutputSpec, AggregateMap, AggregateSchema, ValidatedAggregateMap, ColumnDef, CollapseData, ArrayAggregateAppendSchema, ArrayAggregateKind, ArrayAggregateReplaceSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, CollapseSchema, DedupeKeep, EventDataForSchema, EventForSchema, EventKeyForKind, FillMapping, FillStrategy, MaterializeSchema, EventKeyForSchema, FirstColKind, FirstColumn, IntervalKeyedSchema, JsonIntervalInput, JsonObjectRowForSchema, JsonRowFormat, JsonRowForSchema, JsonTimeRangeInput, JsonTimestampInput, JsonValueForKind, RollingAlignment, RollingSchema, JoinConflictMode, JoinManySchema, PrefixedJoinManySchema, PrefixedJoinSchema, LiveSource, JoinType, JoinSchema, NormalizedRowForSchema, NormalizedObjectRowForSchema, NormalizedObjectRow, NormalizedValueForKind, ReduceResult, RenameData, RenameMap, RenameSchema, RekeySchema, RowForSchema, ArrayValue, ColumnValue, ScalarKind, ScalarValue, CustomAggregateReducer, DiffSchema, NumericColumnNameForSchema, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectData, SelectSchema, SeriesSchema, TimeKeyedSchema, TimeSeriesInput, TimeSeriesJsonInput, TimeRangeKeyedSchema, ValueColumnsForSchema, ValueColumn, ValueForKind, ValueFirstColumn, ValueKeyedSchema, ValueSeriesColumnName, ValueSeriesSchema, ValueSeriesValueColumns, } from './schema/index.js';
33
+ export type { AlignSchema, ArrayColumnNameForSchema, BaselineSchema, AggregateFunction, AggregateReducer, AggregateOutputMap, AggregateOutputSpec, AggregateMap, AggregateSchema, ValidatedAggregateMap, ColumnDef, CollapseData, ArrayAggregateAppendSchema, ArrayAggregateKind, ArrayAggregateReplaceSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, CollapseSchema, DedupeKeep, EventDataForSchema, EventForSchema, EventKeyForKind, FillMapping, FillStrategy, MaterializeSchema, EventKeyForSchema, FirstColKind, FirstColumn, IntervalKeyedSchema, JsonIntervalInput, JsonObjectRowForSchema, JsonRowFormat, JsonRowForSchema, JsonTimeRangeInput, JsonTimestampInput, JsonValueForKind, RollingAlignment, RollingSchema, JoinConflictMode, JoinManySchema, PrefixedJoinManySchema, PrefixedJoinSchema, LiveSource, JoinType, JoinSchema, NormalizedRowForSchema, NormalizedObjectRowForSchema, NormalizedObjectRow, NormalizedValueForKind, ReduceResult, RenameData, RenameMap, RenameSchema, RekeySchema, RowForSchema, AppendColumn, OptionalNumberColumn, ArrayValue, ColumnValue, ScalarKind, ScalarValue, CustomAggregateReducer, DiffSchema, NumericColumnNameForSchema, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectData, SelectSchema, SeriesSchema, TimeKeyedSchema, TimeSeriesInput, TimeSeriesJsonInput, TimeRangeKeyedSchema, ValueColumnsForSchema, ValueColumn, ValueForKind, ValueFirstColumn, ValueKeyedSchema, ValueSeriesColumnName, ValueSeriesSchema, ValueSeriesValueColumns, } from './schema/index.js';
34
34
  export type { CalendarOptions, CalendarUnit, TimeZoneOptions, } from './core/calendar.js';
35
35
  export type { EventKey, IntervalInput, IntervalValue, TemporalLike, TimeRangeInput, TimestampInput, } from './core/temporal.js';
36
36
  export type { DurationInput } from './core/duration.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.44.1",
3
+ "version": "0.46.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {