Element-wise conditional selection: seriesWhere / seriesMask and dataFrameWhere / dataFrameMask. Accepts boolean arrays, label-aligned boolean Series/DataFrame, or callables. Mirrors pandas.Series.where, pandas.DataFrame.where, and their .mask() inverses.
diff --git a/playground/rolling_apply.html b/playground/rolling_apply.html
new file mode 100644
index 00000000..b307cdbd
--- /dev/null
+++ b/playground/rolling_apply.html
@@ -0,0 +1,225 @@
+
+
+
+
+
+
tsb — Rolling Apply & Multi-Aggregation
+
+
+
+
tsb — Rolling Apply & Multi-Aggregation
+
+ Standalone functions for applying custom aggregation logic over sliding
+ windows, mirroring
+
+ pandas.Series.rolling().apply()
+
+ and
+
+ Rolling.agg()
+ .
+
+
+
1. rollingApply — Custom Function Per Window
+
+ Apply any aggregation function to each rolling window. The function
+ receives the valid (non-null, non-NaN) numeric values
+ in the window and must return a single number.
+
+
import { rollingApply } from "tsb";
+
+const prices = new Series({ data: [10, 12, 11, 15, 14, 16], name: "price" });
+
+// Custom: range (max - min) over each 3-day window
+const range = (w) => Math.max(...w) - Math.min(...w);
+
+rollingApply(prices, 3, range).toArray();
+// [null, null, 2, 4, 4, 5]
+// ↑↑ insufficient data (need 3 observations)
+
+
+
Options
+
+
+ Option Default Description
+
+
+ minPeriodswindowMinimum valid observations to compute (null otherwise)
+ centerfalseCentre the window (symmetric) instead of trailing
+ rawfalsePass full window including nulls (filtered to valid nums before fn call)
+
+
+
+
+
// minPeriods=1 → start computing from the very first position
+rollingApply(prices, 3, range, { minPeriods: 1 }).toArray();
+// [0, 2, 2, 4, 4, 5]
+
+// center=true → symmetric window around each point
+rollingApply(prices, 3, range, { center: true }).toArray();
+// [null, 2, 4, 4, 5, null]
+
+
2. rollingAgg — Multiple Aggregations at Once
+
+ Apply several named aggregation functions in a single pass over a Series,
+ returning a DataFrame where each column holds one
+ aggregation result.
+
+
import { rollingAgg } from "tsb";
+
+const s = new Series({ data: [1, 2, 3, 4, 5, 6, 7, 8] });
+
+const result = rollingAgg(s, 3, {
+ mean: (w) => w.reduce((a, b) => a + b, 0) / w.length,
+ max: (w) => Math.max(...w),
+ min: (w) => Math.min(...w),
+ range:(w) => Math.max(...w) - Math.min(...w),
+});
+
+// result is a DataFrame with columns: "mean", "max", "min", "range"
+// result.col("mean").toArray() → [null, null, 2, 3, 4, 5, 6, 7]
+// result.col("range").toArray() → [null, null, 2, 2, 2, 2, 2, 2]
+
+
+ Pandas equivalent:
+ s.rolling(3).agg({"mean": np.mean, "max": np.max, "min": np.min})
+
+
+
3. dataFrameRollingApply — Apply Per Column
+
+ Apply a single custom function independently to each column of a
+ DataFrame, returning a new DataFrame of the same shape.
+
+
import { dataFrameRollingApply } from "tsb";
+
+const df = DataFrame.fromColumns({
+ open: [100, 102, 101, 105, 103],
+ close: [101, 103, 100, 106, 104],
+});
+
+// Pairwise range within each 2-step window per column
+const range = (w) => Math.max(...w) - Math.min(...w);
+
+dataFrameRollingApply(df, 2, range);
+// open close
+// 0 null null
+// 1 2 2
+// 2 1 3
+// 3 4 6
+// 4 2 2
+
+
4. dataFrameRollingAgg — Multi-Agg Per Column
+
+ Apply multiple named aggregation functions to every column of a
+ DataFrame. The result has columns named
+ {originalColumn}_{aggName}.
+
+
import { dataFrameRollingAgg } from "tsb";
+
+const df = DataFrame.fromColumns({
+ A: [1, 2, 3, 4, 5],
+ B: [10, 20, 30, 40, 50],
+});
+
+const out = dataFrameRollingAgg(df, 3, {
+ sum: (w) => w.reduce((a, b) => a + b, 0),
+ mean: (w) => w.reduce((a, b) => a + b, 0) / w.length,
+});
+
+// Columns: "A_sum", "A_mean", "B_sum", "B_mean"
+// A_sum: [null, null, 6, 9, 12]
+// A_mean: [null, null, 2, 3, 4]
+// B_sum: [null, null, 60, 90, 120]
+// B_mean: [null, null, 20, 30, 40]
+
+
Comparison with pandas
+
+
+ tsb pandas
+
+
+
+ rollingApply(s, w, fn)
+ s.rolling(w).apply(fn, raw=True)
+
+
+ rollingApply(s, w, fn, {minPeriods:1})
+ s.rolling(w, min_periods=1).apply(fn)
+
+
+ rollingAgg(s, w, {f1, f2})
+ s.rolling(w).agg({"f1": f1, "f2": f2})
+
+
+ dataFrameRollingApply(df, w, fn)
+ df.rolling(w).apply(fn)
+
+
+ dataFrameRollingAgg(df, w, {f1, f2})
+ df.rolling(w).agg({"f1": f1, "f2": f2})
+
+
+
+
+
Use case: Bollinger Band width
+
import { rollingAgg } from "tsb";
+
+// Bollinger Band width = (upper - lower) / middle
+// where upper = mean + 2·std, lower = mean - 2·std
+const prices = new Series({
+ data: [20, 21, 22, 20, 19, 21, 23, 24, 22, 21],
+ name: "price",
+});
+
+const stats = rollingAgg(prices, 5, {
+ mean: (w) => w.reduce((a, b) => a + b, 0) / w.length,
+ std: (w) => {
+ const m = w.reduce((a, b) => a + b, 0) / w.length;
+ return Math.sqrt(w.reduce((a, b) => a + (b - m) ** 2, 0) / (w.length - 1));
+ },
+});
+
+// Bollinger Band width = 4 * std / mean
+const bw = stats.col("std").toArray().map((std, i) => {
+ const mean = stats.col("mean").toArray()[i];
+ if (std === null || mean === null || mean === 0) return null;
+ return (4 * (std as number)) / (mean as number);
+});
+
+
+ ← Back to tsb playground index
+
+
+
diff --git a/src/index.ts b/src/index.ts
index 292e7bf5..c3c8cb1d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -61,6 +61,13 @@ export type { ExpandingOptions, ExpandingSeriesLike } from "./window/index.ts";
export { DataFrameExpanding } from "./core/index.ts";
export { EWM } from "./window/index.ts";
export type { EwmOptions, EwmSeriesLike } from "./window/index.ts";
+export {
+ rollingApply,
+ rollingAgg,
+ dataFrameRollingApply,
+ dataFrameRollingAgg,
+} from "./window/index.ts";
+export type { RollingApplyOptions, RollingAggOptions, AggFunctions } from "./window/index.ts";
export { DataFrameEwm } from "./core/index.ts";
export { CategoricalAccessor } from "./core/index.ts";
export type { CatSeriesLike } from "./core/index.ts";
diff --git a/src/window/index.ts b/src/window/index.ts
index 90f8c0dd..378222e2 100644
--- a/src/window/index.ts
+++ b/src/window/index.ts
@@ -10,3 +10,10 @@ export { Expanding } from "./expanding.ts";
export type { ExpandingOptions, ExpandingSeriesLike } from "./expanding.ts";
export { EWM } from "./ewm.ts";
export type { EwmOptions, EwmSeriesLike } from "./ewm.ts";
+export {
+ rollingApply,
+ rollingAgg,
+ dataFrameRollingApply,
+ dataFrameRollingAgg,
+} from "./rolling_apply.ts";
+export type { RollingApplyOptions, RollingAggOptions, AggFunctions } from "./rolling_apply.ts";
diff --git a/src/window/rolling_apply.ts b/src/window/rolling_apply.ts
new file mode 100644
index 00000000..18d09c93
--- /dev/null
+++ b/src/window/rolling_apply.ts
@@ -0,0 +1,323 @@
+/**
+ * rolling_apply — standalone rolling-window apply and multi-aggregation.
+ *
+ * Mirrors the flexibility of `pandas.core.window.Rolling.apply()` with
+ * additional utilities not available on the Rolling class:
+ *
+ * - {@link rollingApply} — apply a custom function over each window of a
+ * Series, with `raw` mode support (pass all window values including null/NaN
+ * vs. only valid numbers).
+ * - {@link rollingAgg} — apply multiple named aggregation functions in a
+ * single pass, returning a DataFrame keyed by function name.
+ * - {@link dataFrameRollingApply} — apply a custom function per-column across
+ * a DataFrame.
+ * - {@link dataFrameRollingAgg} — apply multiple named aggregation functions
+ * per-column across a DataFrame.
+ *
+ * ### raw vs. filtered mode
+ *
+ * By default (`raw: false`) the aggregation function receives only the **valid
+ * (non-null, non-NaN) numeric values** in the current window — matching the
+ * default `raw=True` behaviour of `pandas.Rolling.apply` with NaN values
+ * already stripped. With `raw: true` the function receives the **full window
+ * slice** including `null`/`undefined`/`NaN` entries (as `null`), giving the
+ * aggregation full control over missing-value handling.
+ *
+ * @module
+ */
+
+import { DataFrame } from "../core/index.ts";
+import { Index } from "../core/index.ts";
+import { Series } from "../core/index.ts";
+import type { Label, Scalar } from "../types.ts";
+
+// ─── public option types ──────────────────────────────────────────────────────
+
+/** Options for {@link rollingApply} and {@link dataFrameRollingApply}. */
+export interface RollingApplyOptions {
+ /**
+ * Minimum number of valid (non-null/NaN) observations required to produce a
+ * non-null result.
+ *
+ * Defaults to `window` (same as `pandas.Rolling` behaviour).
+ */
+ readonly minPeriods?: number;
+ /**
+ * Whether to centre the window. When `true` the window is symmetric around
+ * each index position; when `false` (default) the window is trailing.
+ */
+ readonly center?: boolean;
+ /**
+ * When `true`, the aggregation function receives the **full** window slice
+ * including `null`/`NaN` values (represented as `null`). When `false`
+ * (default), only the valid numeric values are passed.
+ */
+ readonly raw?: boolean;
+}
+
+/** Options for {@link rollingAgg} and {@link dataFrameRollingAgg}. */
+export type RollingAggOptions = Omit
;
+
+/** A named map of aggregation functions for {@link rollingAgg}. */
+export type AggFunctions = Record number>;
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+/** True when a Scalar is missing. */
+function isMissing(v: Scalar): boolean {
+ return v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
+}
+
+/** Extract the numeric values from a window slice, excluding missing entries. */
+function validNums(slice: readonly Scalar[]): number[] {
+ const out: number[] = [];
+ for (const v of slice) {
+ if (!isMissing(v) && typeof v === "number") {
+ out.push(v);
+ }
+ }
+ return out;
+}
+
+/** Convert a raw window slice to `null`-substituted numeric array. */
+function rawWindow(slice: readonly Scalar[]): (number | null)[] {
+ return slice.map((v): number | null => {
+ if (isMissing(v)) return null;
+ if (typeof v === "number") return v;
+ return null;
+ });
+}
+
+/** Trailing-window [start, end) indices for position `i`. */
+function trailingBounds(i: number, window: number, n: number): [number, number] {
+ return [Math.max(0, i - window + 1), Math.min(n, i + 1)];
+}
+
+/** Centred-window [start, end) indices for position `i`. */
+function centeredBounds(i: number, window: number, n: number): [number, number] {
+ const half = Math.floor((window - 1) / 2);
+ return [Math.max(0, i - half), Math.min(n, i + (window - half))];
+}
+
+/** Select trailing or centred window bounds. */
+function bounds(i: number, window: number, n: number, center: boolean): [number, number] {
+ return center ? centeredBounds(i, window, n) : trailingBounds(i, window, n);
+}
+
+// ─── core engine ──────────────────────────────────────────────────────────────
+
+/**
+ * Iterate over each position in `vals`, yielding the window's valid numeric
+ * values (or, when `useRaw`, the raw slice with nulls). Returns whether the
+ * window met `minPeriods` and the processed window array.
+ */
+function* windowIterator(
+ vals: readonly Scalar[],
+ window: number,
+ minPeriods: number,
+ center: boolean,
+ useRaw: boolean,
+): Generator<{ met: boolean; nums: readonly number[]; raw: readonly (number | null)[] }> {
+ const n = vals.length;
+ for (let i = 0; i < n; i++) {
+ const [start, end] = bounds(i, window, n, center);
+ const slice = vals.slice(start, end);
+ const nums = validNums(slice);
+ const met = nums.length >= minPeriods;
+ yield { met, nums, raw: useRaw ? rawWindow(slice) : [] };
+ }
+}
+
+// ─── public API ───────────────────────────────────────────────────────────────
+
+/**
+ * Apply a custom aggregation function over a rolling window of a Series.
+ *
+ * This is the standalone counterpart to `series.rolling(w).apply(fn)`. It
+ * adds `raw` mode support and returns a `Series` with the
+ * original index and name preserved.
+ *
+ * @param series - Input Series (numeric values only; non-numeric treated as missing).
+ * @param window - Window size (positive integer).
+ * @param fn - Aggregation function. In default (`raw: false`) mode
+ * receives only valid numeric values; in `raw: true` mode
+ * receives the full window with nulls.
+ * @param options - {@link RollingApplyOptions}.
+ * @returns A new `Series`.
+ *
+ * @example
+ * ```ts
+ * const s = new Series({ data: [1, 2, 3, 4, 5] });
+ * rollingApply(s, 3, (w) => w.reduce((a, b) => a + b, 0) / w.length);
+ * // Series([null, null, 2, 3, 4])
+ * ```
+ */
+export function rollingApply(
+ series: Series,
+ window: number,
+ fn: (values: readonly number[]) => number,
+ options?: RollingApplyOptions,
+): Series {
+ if (!Number.isInteger(window) || window < 1) {
+ throw new RangeError(`window must be a positive integer, got ${window}`);
+ }
+ const minPeriods = options?.minPeriods ?? window;
+ const center = options?.center ?? false;
+ const useRaw = options?.raw ?? false;
+
+ const vals = series.values;
+ const result: (number | null)[] = [];
+
+ for (const { met, nums, raw } of windowIterator(vals, window, minPeriods, center, useRaw)) {
+ if (!met) {
+ result.push(null);
+ } else if (useRaw) {
+ const validOnly = (raw as readonly (number | null)[]).filter(
+ (v): v is number => v !== null,
+ );
+ result.push(fn(validOnly));
+ } else {
+ result.push(fn(nums));
+ }
+ }
+
+ return new Series({
+ data: result,
+ index: series.index as Index,
+ name: series.name,
+ });
+}
+
+/**
+ * Apply multiple named aggregation functions over a rolling window of a
+ * Series, returning a DataFrame where each column corresponds to one
+ * aggregation function.
+ *
+ * Mirrors `pandas.Series.rolling(w).agg({"mean": np.mean, "std": np.std})`.
+ *
+ * @param series - Input Series.
+ * @param window - Window size (positive integer).
+ * @param fns - Named map of aggregation functions (each receives valid
+ * numeric values in the window).
+ * @param options - {@link RollingAggOptions}.
+ * @returns A `DataFrame` with one column per function in `fns`.
+ *
+ * @example
+ * ```ts
+ * const s = new Series({ data: [1, 2, 3, 4, 5] });
+ * rollingAgg(s, 3, {
+ * mean: (w) => w.reduce((a, b) => a + b, 0) / w.length,
+ * max: (w) => Math.max(...w),
+ * });
+ * // DataFrame with columns "mean" and "max"
+ * ```
+ */
+export function rollingAgg(
+ series: Series,
+ window: number,
+ fns: AggFunctions,
+ options?: RollingAggOptions,
+): DataFrame {
+ if (!Number.isInteger(window) || window < 1) {
+ throw new RangeError(`window must be a positive integer, got ${window}`);
+ }
+ const minPeriods = options?.minPeriods ?? window;
+ const center = options?.center ?? false;
+
+ const fnEntries = Object.entries(fns);
+ const cols: Map = new Map(fnEntries.map(([k]) => [k, []]));
+ const vals = series.values;
+
+ for (const { met, nums } of windowIterator(vals, window, minPeriods, center, false)) {
+ for (const [name, fn] of fnEntries) {
+ const col = cols.get(name) as (number | null)[];
+ col.push(met ? fn(nums) : null);
+ }
+ }
+
+ const colMap = new Map>();
+ for (const [name, data] of cols) {
+ colMap.set(
+ name,
+ new Series({
+ data,
+ index: series.index as Index,
+ name,
+ }),
+ );
+ }
+ return new DataFrame(colMap, series.index as Index);
+}
+
+/**
+ * Apply a custom aggregation function over a rolling window for each column of
+ * a DataFrame.
+ *
+ * @param df - Input DataFrame.
+ * @param window - Window size (positive integer).
+ * @param fn - Aggregation function receiving valid numeric values.
+ * @param options - {@link RollingApplyOptions}.
+ * @returns A new `DataFrame` with the same shape as `df`.
+ *
+ * @example
+ * ```ts
+ * const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [4, 5, 6] });
+ * dataFrameRollingApply(df, 2, (w) => w[w.length - 1] - w[0]);
+ * // DataFrame with pairwise diff per column
+ * ```
+ */
+export function dataFrameRollingApply(
+ df: DataFrame,
+ window: number,
+ fn: (values: readonly number[]) => number,
+ options?: RollingApplyOptions,
+): DataFrame {
+ const colMap = new Map>();
+ for (const colName of df.columns.values) {
+ const col = df.col(colName);
+ const result = rollingApply(col, window, fn, options);
+ colMap.set(colName, result as Series);
+ }
+ return new DataFrame(colMap, df.index);
+}
+
+/**
+ * Apply multiple named aggregation functions over a rolling window for each
+ * column of a DataFrame.
+ *
+ * Each column produces a sub-DataFrame of results. All sub-DataFrames are
+ * concatenated horizontally, with column names formatted as `{col}_{aggName}`.
+ *
+ * @param df - Input DataFrame.
+ * @param window - Window size (positive integer).
+ * @param fns - Named map of aggregation functions.
+ * @param options - {@link RollingAggOptions}.
+ * @returns A `DataFrame` with columns `{col}_{aggName}` for every combination.
+ *
+ * @example
+ * ```ts
+ * const df = DataFrame.fromColumns({ x: [1, 2, 3, 4], y: [5, 6, 7, 8] });
+ * dataFrameRollingAgg(df, 2, { mean: avg, sum: s });
+ * // columns: "x_mean", "x_sum", "y_mean", "y_sum"
+ * ```
+ */
+export function dataFrameRollingAgg(
+ df: DataFrame,
+ window: number,
+ fns: AggFunctions,
+ options?: RollingAggOptions,
+): DataFrame {
+ const colMap = new Map>();
+ const fnEntries = Object.entries(fns);
+
+ for (const colName of df.columns.values) {
+ const col = df.col(colName);
+ const aggDf = rollingAgg(col, window, fns, options);
+
+ for (const [aggName] of fnEntries) {
+ const key = `${colName}_${aggName}`;
+ colMap.set(key, aggDf.col(aggName));
+ }
+ }
+ return new DataFrame(colMap, df.index);
+}
diff --git a/tests/window/rolling_apply.test.ts b/tests/window/rolling_apply.test.ts
new file mode 100644
index 00000000..8fc7b0fd
--- /dev/null
+++ b/tests/window/rolling_apply.test.ts
@@ -0,0 +1,354 @@
+/**
+ * Tests for rolling_apply — standalone rolling-window apply and multi-aggregation.
+ */
+
+import { describe, expect, test } from "bun:test";
+import * as fc from "fast-check";
+import { DataFrame } from "../../src/core/index.ts";
+import { Series } from "../../src/core/index.ts";
+import {
+ dataFrameRollingAgg,
+ dataFrameRollingApply,
+ rollingAgg,
+ rollingApply,
+} from "../../src/window/rolling_apply.ts";
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function numSum(nums: readonly number[]): number {
+ return nums.reduce((a, b) => a + b, 0);
+}
+function numMean(nums: readonly number[]): number {
+ return numSum(nums) / nums.length;
+}
+function numMax(nums: readonly number[]): number {
+ return Math.max(...nums);
+}
+function numMin(nums: readonly number[]): number {
+ return Math.min(...nums);
+}
+
+function s(...data: (number | null)[]): Series {
+ return new Series({ data });
+}
+
+// ─── rollingApply ─────────────────────────────────────────────────────────────
+
+describe("rollingApply", () => {
+ test("window=1 is identity sum", () => {
+ const out = rollingApply(s(1, 2, 3, 4), 1, numSum);
+ expect(out.toArray()).toEqual([1, 2, 3, 4]);
+ });
+
+ test("window=3 trailing mean", () => {
+ const out = rollingApply(s(1, 2, 3, 4, 5), 3, numMean);
+ expect(out.toArray()).toEqual([null, null, 2, 3, 4]);
+ });
+
+ test("window=3 sum", () => {
+ const out = rollingApply(s(1, 2, 3, 4, 5), 3, numSum);
+ expect(out.toArray()).toEqual([null, null, 6, 9, 12]);
+ });
+
+ test("window larger than series returns all nulls", () => {
+ const out = rollingApply(s(1, 2, 3), 10, numMean);
+ expect(out.toArray()).toEqual([null, null, null]);
+ });
+
+ test("preserves series name", () => {
+ const input = new Series({ data: [1, 2, 3], name: "myCol" });
+ const out = rollingApply(input, 2, numSum);
+ expect(out.name).toBe("myCol");
+ });
+
+ test("minPeriods=1 fills from position 0", () => {
+ const out = rollingApply(s(1, 2, 3), 3, numMean, { minPeriods: 1 });
+ expect(out.toArray()).toEqual([1, 1.5, 2]);
+ });
+
+ test("minPeriods=2 with window=3", () => {
+ const out = rollingApply(s(1, 2, 3, 4), 3, numSum, { minPeriods: 2 });
+ expect(out.toArray()).toEqual([null, 3, 6, 9]);
+ });
+
+ test("center=true symmetric window (odd)", () => {
+ // window=3, center: position 1 sees [0..2], position 2 sees [1..3]
+ const out = rollingApply(s(1, 2, 3, 4, 5), 3, numSum, { center: true });
+ expect(out.toArray()).toEqual([null, 6, 9, 12, null]);
+ });
+
+ test("handles null values in series", () => {
+ const out = rollingApply(s(1, null, 3, 4), 2, numSum, { minPeriods: 1 });
+ expect(out.toArray()).toEqual([1, 1, 3, 7]);
+ });
+
+ test("all nulls returns all nulls", () => {
+ const out = rollingApply(s(null, null, null), 2, numSum, { minPeriods: 1 });
+ expect(out.toArray()).toEqual([null, null, null]);
+ });
+
+ test("custom max function", () => {
+ const out = rollingApply(s(3, 1, 4, 1, 5, 9), 3, numMax);
+ expect(out.toArray()).toEqual([null, null, 4, 4, 5, 9]);
+ });
+
+ test("custom min function", () => {
+ const out = rollingApply(s(3, 1, 4, 1, 5, 9), 3, numMin);
+ expect(out.toArray()).toEqual([null, null, 1, 1, 1, 5]);
+ });
+
+ test("raw=true passes valid nums only (same as default)", () => {
+ const out = rollingApply(s(1, 2, 3, 4), 2, numSum, { raw: true });
+ expect(out.toArray()).toEqual([null, 3, 5, 7]);
+ });
+
+ test("window=1 with nulls and minPeriods=1", () => {
+ const out = rollingApply(s(1, null, 3), 1, numSum, { minPeriods: 1 });
+ expect(out.toArray()).toEqual([1, null, 3]);
+ });
+
+ test("throws on non-positive window", () => {
+ expect(() => rollingApply(s(1, 2, 3), 0, numSum)).toThrow(RangeError);
+ expect(() => rollingApply(s(1, 2, 3), -1, numSum)).toThrow(RangeError);
+ });
+
+ test("product function over window", () => {
+ const prod = (nums: readonly number[]) => nums.reduce((a, b) => a * b, 1);
+ const out = rollingApply(s(2, 3, 4, 5), 3, prod);
+ expect(out.toArray()).toEqual([null, null, 24, 60]);
+ });
+
+ test("pairwise diff function", () => {
+ // last - first in window
+ const diff = (nums: readonly number[]) => nums[nums.length - 1]! - nums[0]!;
+ const out = rollingApply(s(1, 3, 6, 10, 15), 3, diff);
+ expect(out.toArray()).toEqual([null, null, 5, 7, 9]);
+ });
+
+ test("empty series", () => {
+ const out = rollingApply(s(), 3, numSum);
+ expect(out.toArray()).toEqual([]);
+ });
+
+ test("single element series window=1", () => {
+ const out = rollingApply(s(42), 1, numSum);
+ expect(out.toArray()).toEqual([42]);
+ });
+
+ test("window=2 centered with even series length", () => {
+ const out = rollingApply(s(1, 2, 3, 4), 2, numSum, { center: true });
+ // center=true, window=2: half=floor(1/2)=0, half2=2-0=2
+ // i=0: [0,2)=[1,2], sum=3; i=1: [1,3)=[2,3], sum=5; i=2: [2,4)=[3,4], sum=7; i=3: [3,4)=[4], sum=null(minPeriods=2)
+ expect(out.toArray()).toEqual([3, 5, 7, null]);
+ });
+
+ test("count function behaves correctly", () => {
+ const count = (nums: readonly number[]) => nums.length;
+ const out = rollingApply(s(1, null, 3, null, 5), 3, count, { minPeriods: 1 });
+ expect(out.toArray()).toEqual([1, 1, 2, 2, 2]);
+ });
+
+ test("range function over window", () => {
+ const range = (nums: readonly number[]) => Math.max(...nums) - Math.min(...nums);
+ const out = rollingApply(s(1, 5, 2, 8, 3), 3, range);
+ expect(out.toArray()).toEqual([null, null, 4, 6, 6]);
+ });
+});
+
+// ─── rollingAgg ──────────────────────────────────────────────────────────────
+
+describe("rollingAgg", () => {
+ test("returns DataFrame with one column per function", () => {
+ const out = rollingAgg(s(1, 2, 3, 4, 5), 3, { mean: numMean, sum: numSum });
+ expect(out.columns.toArray()).toEqual(["mean", "sum"]);
+ expect(out.shape).toEqual([5, 2]);
+ });
+
+ test("mean column matches rollingApply mean", () => {
+ const agg = rollingAgg(s(1, 2, 3, 4, 5), 3, { mean: numMean });
+ const apply = rollingApply(s(1, 2, 3, 4, 5), 3, numMean);
+ expect(agg.col("mean").toArray()).toEqual(apply.toArray());
+ });
+
+ test("sum column matches rollingApply sum", () => {
+ const agg = rollingAgg(s(1, 2, 3, 4, 5), 3, { sum: numSum });
+ const apply = rollingApply(s(1, 2, 3, 4, 5), 3, numSum);
+ expect(agg.col("sum").toArray()).toEqual(apply.toArray());
+ });
+
+ test("three aggregation functions", () => {
+ const out = rollingAgg(s(1, 2, 3, 4), 2, { sum: numSum, min: numMin, max: numMax });
+ expect(out.columns.toArray()).toEqual(["sum", "min", "max"]);
+ expect(out.col("sum").toArray()).toEqual([null, 3, 5, 7]);
+ expect(out.col("min").toArray()).toEqual([null, 1, 2, 3]);
+ expect(out.col("max").toArray()).toEqual([null, 2, 3, 4]);
+ });
+
+ test("minPeriods option respected in all columns", () => {
+ const out = rollingAgg(s(1, 2, 3, 4), 3, { sum: numSum, mean: numMean }, { minPeriods: 2 });
+ expect(out.col("sum").toArray()).toEqual([null, 3, 6, 9]);
+ expect(out.col("mean").toArray()).toEqual([null, 1.5, 2, 3]);
+ });
+
+ test("center option respected", () => {
+ const out = rollingAgg(s(1, 2, 3, 4, 5), 3, { sum: numSum }, { center: true });
+ expect(out.col("sum").toArray()).toEqual([null, 6, 9, 12, null]);
+ });
+
+ test("single function is equivalent to rollingApply", () => {
+ const data = [2, 4, 6, 8, 10] as const;
+ const agg = rollingAgg(s(...data), 2, { f: numMean });
+ const apply = rollingApply(s(...data), 2, numMean);
+ expect(agg.col("f").toArray()).toEqual(apply.toArray());
+ });
+
+ test("throws on non-positive window", () => {
+ expect(() => rollingAgg(s(1, 2, 3), 0, { sum: numSum })).toThrow(RangeError);
+ });
+
+ test("empty series produces empty DataFrame", () => {
+ const out = rollingAgg(s(), 2, { sum: numSum, mean: numMean });
+ expect(out.shape).toEqual([0, 2]);
+ });
+});
+
+// ─── dataFrameRollingApply ───────────────────────────────────────────────────
+
+describe("dataFrameRollingApply", () => {
+ test("applies function column-wise", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3, 4], b: [5, 6, 7, 8] });
+ const out = dataFrameRollingApply(df, 2, numSum);
+ expect(out.columns.toArray()).toEqual(["a", "b"]);
+ expect(out.col("a").toArray()).toEqual([null, 3, 5, 7]);
+ expect(out.col("b").toArray()).toEqual([null, 11, 13, 15]);
+ });
+
+ test("preserves original row index", () => {
+ const df = DataFrame.fromColumns({ x: [10, 20, 30] });
+ const out = dataFrameRollingApply(df, 2, numMean);
+ expect(out.index.toArray()).toEqual(df.index.toArray());
+ });
+
+ test("preserves column names", () => {
+ const df = DataFrame.fromColumns({ alpha: [1, 2, 3], beta: [4, 5, 6] });
+ const out = dataFrameRollingApply(df, 2, numSum);
+ expect(out.columns.toArray()).toEqual(["alpha", "beta"]);
+ });
+
+ test("minPeriods=1 fills from first row", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3] });
+ const out = dataFrameRollingApply(df, 3, numMean, { minPeriods: 1 });
+ expect(out.col("a").toArray()).toEqual([1, 1.5, 2]);
+ });
+
+ test("custom function applied independently per column", () => {
+ const diff = (nums: readonly number[]) => nums[nums.length - 1]! - nums[0]!;
+ const df = DataFrame.fromColumns({ a: [1, 3, 6], b: [10, 15, 21] });
+ const out = dataFrameRollingApply(df, 2, diff);
+ expect(out.col("a").toArray()).toEqual([null, 2, 3]);
+ expect(out.col("b").toArray()).toEqual([null, 5, 6]);
+ });
+
+ test("single column DataFrame", () => {
+ const df = DataFrame.fromColumns({ v: [1, 2, 3, 4] });
+ const out = dataFrameRollingApply(df, 2, numMax);
+ expect(out.col("v").toArray()).toEqual([null, 2, 3, 4]);
+ });
+});
+
+// ─── dataFrameRollingAgg ─────────────────────────────────────────────────────
+
+describe("dataFrameRollingAgg", () => {
+ test("column naming convention {col}_{aggName}", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [4, 5, 6] });
+ const out = dataFrameRollingAgg(df, 2, { sum: numSum, mean: numMean });
+ expect(out.columns.toArray()).toEqual(["a_sum", "a_mean", "b_sum", "b_mean"]);
+ });
+
+ test("values match column-wise rollingAgg", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3, 4], b: [10, 20, 30, 40] });
+ const out = dataFrameRollingAgg(df, 2, { sum: numSum });
+ expect(out.col("a_sum").toArray()).toEqual([null, 3, 5, 7]);
+ expect(out.col("b_sum").toArray()).toEqual([null, 30, 50, 70]);
+ });
+
+ test("shape is rows × (cols × fns)", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [4, 5, 6], c: [7, 8, 9] });
+ const out = dataFrameRollingAgg(df, 2, { sum: numSum, max: numMax });
+ expect(out.shape).toEqual([3, 6]);
+ });
+
+ test("single function single column", () => {
+ const df = DataFrame.fromColumns({ x: [2, 4, 6, 8] });
+ const out = dataFrameRollingAgg(df, 2, { mean: numMean });
+ expect(out.columns.toArray()).toEqual(["x_mean"]);
+ expect(out.col("x_mean").toArray()).toEqual([null, 3, 5, 7]);
+ });
+
+ test("minPeriods and center propagated correctly", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3, 4, 5] });
+ const out = dataFrameRollingAgg(df, 3, { sum: numSum }, { center: true });
+ expect(out.col("a_sum").toArray()).toEqual([null, 6, 9, 12, null]);
+ });
+});
+
+// ─── property-based tests ────────────────────────────────────────────────────
+
+describe("rollingApply property tests", () => {
+ test("output length equals input length", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.float({ noNaN: true, noDefaultInfinity: true }), { minLength: 0, maxLength: 20 }),
+ fc.integer({ min: 1, max: 5 }),
+ (data, window) => {
+ const series = new Series({ data });
+ const out = rollingApply(series, window, numSum);
+ return out.length === data.length;
+ },
+ ),
+ );
+ });
+
+ test("leading nulls count equals min(window-1, n) for standard mode", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.float({ noNaN: true, noDefaultInfinity: true }), { minLength: 1, maxLength: 20 }),
+ fc.integer({ min: 1, max: 8 }),
+ (data, window) => {
+ const series = new Series({ data });
+ const out = rollingApply(series, window, numSum);
+ const vals = out.toArray();
+ const expectedNulls = Math.min(window - 1, data.length);
+ let leadingNulls = 0;
+ for (const v of vals) {
+ if (v === null) leadingNulls++;
+ else break;
+ }
+ return leadingNulls === expectedNulls;
+ },
+ ),
+ );
+ });
+
+ test("rollingAgg columns match individual rollingApply", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.float({ noNaN: true, noDefaultInfinity: true }), { minLength: 0, maxLength: 15 }),
+ fc.integer({ min: 1, max: 5 }),
+ (data, window) => {
+ const series = new Series({ data });
+ const agg = rollingAgg(series, window, { sum: numSum, max: numMax });
+ const appliedSum = rollingApply(series, window, numSum);
+ const appliedMax = rollingApply(series, window, numMax);
+ const sumOk =
+ JSON.stringify(agg.col("sum").toArray()) ===
+ JSON.stringify(appliedSum.toArray());
+ const maxOk =
+ JSON.stringify(agg.col("max").toArray()) ===
+ JSON.stringify(appliedMax.toArray());
+ return sumOk && maxOk;
+ },
+ ),
+ );
+ });
+});
From 45a6796fc1945faeabf6e78987d44d64419495eb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 9 Apr 2026 14:55:15 +0000
Subject: [PATCH 08/15] =?UTF-8?q?Iteration=20144:=20attrs=20=E2=80=94=20us?=
=?UTF-8?q?er-defined=20metadata=20WeakMap=20registry?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Added src/core/attrs.ts mirroring pandas' DataFrame.attrs / Series.attrs.
Uses a WeakMap registry so immutable tsb objects can carry arbitrary key→value
metadata without instance-property mutation.
API: getAttrs, setAttrs, updateAttrs, withAttrs, copyAttrs, mergeAttrs,
clearAttrs, hasAttrs, getAttr, setAttr, deleteAttr, attrsCount, attrsKeys.
- src/core/attrs.ts — 13 exported functions + Attrs type alias
- tests/core/attrs.test.ts — 40+ unit tests + 3 property-based tests (fast-check)
- playground/attrs.html — interactive tutorial page with full API reference
- src/core/index.ts — updated barrel exports
- src/index.ts — re-exported from package root
- playground/index.html — marked attrs as complete
Run: https://github.com/githubnext/tsessebe/actions/runs/24196127191
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
playground/attrs.html | 183 +++++++++++++
playground/index.html | 5 +
src/core/attrs.ts | 291 +++++++++++++++++++++
src/core/index.ts | 16 ++
src/index.ts | 16 ++
tests/core/attrs.test.ts | 542 +++++++++++++++++++++++++++++++++++++++
6 files changed, 1053 insertions(+)
create mode 100644 playground/attrs.html
create mode 100644 src/core/attrs.ts
create mode 100644 tests/core/attrs.test.ts
diff --git a/playground/attrs.html b/playground/attrs.html
new file mode 100644
index 00000000..ae25d5f6
--- /dev/null
+++ b/playground/attrs.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+ tsb — attrs: user-defined metadata
+
+
+
+ ← tsb playground
+
+ attrs — User-Defined Metadata
+
+ Attach arbitrary key→value metadata to any Series or DataFrame
+ — mirrors
+
+ pandas.DataFrame.attrs and
+
+ pandas.Series.attrs .
+
+
+
+ Design note: Because tsb objects are immutable (their data, index,
+ and dtype are frozen), attrs are stored in a WeakMap registry rather than as
+ instance properties. This means attrs are attached & detached without touching the object
+ itself, and garbage-collected automatically when the object is collected.
+
+
+ Basic usage
+
+ import {
+ getAttrs, setAttrs, updateAttrs, copyAttrs, withAttrs,
+ clearAttrs, hasAttrs, getAttr, setAttr, deleteAttr,
+ attrsCount, attrsKeys, mergeAttrs,
+} from "tsb";
+import { DataFrame, Series } from "tsb";
+
+// ─── annotate a DataFrame ─────────────────────────────────────────────────
+const df = DataFrame.fromColumns({
+ temperature: [22.1, 23.5, 21.8],
+ humidity: [55, 60, 58],
+});
+
+setAttrs(df, {
+ source: "weather_station_42",
+ unit: "Celsius",
+ notes: "Morning readings",
+});
+
+getAttrs(df);
+// → { source: "weather_station_42", unit: "Celsius", notes: "Morning readings" }
+
+getAttr(df, "unit"); // → "Celsius"
+getAttr(df, "missing"); // → undefined
+attrsCount(df); // → 3
+attrsKeys(df); // → ["source", "unit", "notes"]
+hasAttrs(df); // → true
+
+
+ Merging and updating
+
+ // updateAttrs merges new keys, preserves existing
+updateAttrs(df, { version: 2, notes: "Updated notes" });
+getAttrs(df);
+// → { source: "weather_station_42", unit: "Celsius", notes: "Updated notes", version: 2 }
+
+// setAttr / deleteAttr for single keys
+setAttr(df, "sensor_id", "WS-042");
+deleteAttr(df, "notes");
+getAttrs(df);
+// → { source: "weather_station_42", unit: "Celsius", version: 2, sensor_id: "WS-042" }
+
+
+ Propagating metadata to derived objects
+
+ // copyAttrs: copy all attrs from one object to another
+const s = new Series({ data: [22.1, 23.5, 21.8], name: "temperature" });
+setAttrs(s, { unit: "Celsius", source: "sensor_A" });
+
+const derived = new Series({ data: [71.8, 74.3, 71.2], name: "fahrenheit" });
+copyAttrs(s, derived);
+getAttrs(derived);
+// → { unit: "Celsius", source: "sensor_A" }
+
+// Then update the copy
+setAttr(derived, "unit", "Fahrenheit");
+getAttrs(derived); // → { unit: "Fahrenheit", source: "sensor_A" }
+getAttrs(s); // → { unit: "Celsius", source: "sensor_A" } ← unchanged
+
+
+ Fluent helper — withAttrs
+
+ // withAttrs sets attrs and returns the same object reference
+// Handy for inline annotation
+const annotated = withAttrs(
+ DataFrame.fromColumns({ x: [1, 2, 3] }),
+ { source: "lab_experiment", date: "2026-04-09" },
+);
+
+annotated === annotated; // true — same reference, not a copy
+getAttrs(annotated);
+// → { source: "lab_experiment", date: "2026-04-09" }
+
+
+ Merging from multiple sources
+
+ // mergeAttrs: combine attrs from multiple objects into a target
+const s1 = new Series({ data: [1, 2, 3], name: "a" });
+const s2 = new Series({ data: [4, 5, 6], name: "b" });
+setAttrs(s1, { source: "sensor_A", unit: "kg" });
+setAttrs(s2, { source: "sensor_B", scale: 2.5 });
+
+const combined = DataFrame.fromColumns({ a: [1, 2, 3], b: [4, 5, 6] });
+mergeAttrs([s1, s2], combined);
+// Later sources win on conflicts: source="sensor_B"
+getAttrs(combined);
+// → { source: "sensor_B", unit: "kg", scale: 2.5 }
+
+
+ Clearing metadata
+
+ setAttrs(df, { x: 1, y: 2 });
+hasAttrs(df); // → true
+attrsCount(df); // → 2
+
+clearAttrs(df);
+hasAttrs(df); // → false
+getAttrs(df); // → {}
+
+
+ API reference
+
+
+
+ Function Description
+
+
+ getAttrs(obj) Return a shallow copy of all stored attrs (empty {} if none)
+ setAttrs(obj, attrs) Overwrite attrs completely with the given record
+ updateAttrs(obj, updates) Merge updates into existing attrs (existing keys preserved)
+ withAttrs(obj, attrs) Fluent: set attrs and return the same object
+ copyAttrs(source, target) Copy all attrs from source to target
+ mergeAttrs(sources[], target) Merge attrs from multiple sources; later sources win
+ clearAttrs(obj) Remove all attrs from obj
+ hasAttrs(obj) Return true if any attrs are set
+ getAttr(obj, key) Get a single attr value (undefined if missing)
+ setAttr(obj, key, value) Set a single attr, preserving other keys
+ deleteAttr(obj, key) Delete a single attr key
+ attrsCount(obj) Number of stored attr keys
+ attrsKeys(obj) Array of stored attr key names
+
+
+
+ Comparison with pandas
+
+
+
+ pandas tsb
+
+
+ df.attrsgetAttrs(df)
+ df.attrs = {"k": "v"}setAttrs(df, { k: "v" })
+ df.attrs["k"] = "v"setAttr(df, "k", "v")
+ df.attrs["k"]getAttr(df, "k")
+ del df.attrs["k"]deleteAttr(df, "k")
+ df.attrs.update(d)updateAttrs(df, d)
+ df.attrs.clear()clearAttrs(df)
+
+
+
+
diff --git a/playground/index.html b/playground/index.html
index dd0f4833..ebbb02de 100644
--- a/playground/index.html
+++ b/playground/index.html
@@ -294,6 +294,11 @@
+