Skip to content

Commit 6515842

Browse files
Iteration 143: rollingApply/rollingAgg standalone multi-aggregation
Add `src/window/rolling_apply.ts` — standalone rolling-window apply and multi-aggregation module: - `rollingApply(series, window, fn, opts)` — apply a custom function over each window of a Series; supports `minPeriods`, `center`, and `raw` mode. - `rollingAgg(series, window, namedFns, opts)` — apply multiple named aggregation functions in one pass, returning a DataFrame keyed by name. Mirrors `pandas.Rolling.agg({'mean': np.mean, 'std': np.std})`. - `dataFrameRollingApply(df, window, fn, opts)` — apply custom function per-column across a DataFrame. - `dataFrameRollingAgg(df, window, namedFns, opts)` — multi-agg per-column, producing columns named `{col}_{aggName}`. Tests: 40+ unit tests + 3 property-based tests (fast-check). All pass. Playground: `playground/rolling_apply.html` with interactive tutorial. Run: https://github.com/githubnext/tsessebe/actions/runs/24182985389 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1312374 commit 6515842

6 files changed

Lines changed: 921 additions & 0 deletions

File tree

playground/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ <h3><a href="window_extended.html" style="color: var(--accent); text-decoration:
279279
<p>Higher-order rolling window statistics: <code>rollingSem</code> (standard error of mean), <code>rollingSkew</code> (Fisher-Pearson skewness), <code>rollingKurt</code> (excess kurtosis), and <code>rollingQuantile</code> (arbitrary percentile with 5 interpolation methods). Mirrors <code>pandas.Series.rolling().sem/skew/kurt/quantile()</code>.</p>
280280
<div class="status done">✅ Complete</div>
281281
</div>
282+
<div class="feature-card">
283+
<h3><a href="rolling_apply.html" style="color: var(--accent); text-decoration: none;">🔧 Rolling Apply &amp; Multi-Agg</a></h3>
284+
<p>Standalone custom rolling-window functions: <code>rollingApply</code> (custom fn per window), <code>rollingAgg</code> (multiple named aggregations → DataFrame), <code>dataFrameRollingApply</code>, <code>dataFrameRollingAgg</code>. Supports <code>minPeriods</code>, <code>center</code>, and <code>raw</code> mode. Mirrors <code>pandas.Rolling.apply()</code> and <code>Rolling.agg()</code>.</p>
285+
<div class="status done">✅ Complete</div>
286+
</div>
282287
<div class="feature-card">
283288
<h3><a href="where_mask.html" style="color: var(--accent); text-decoration: none;">🎭 where / mask</a></h3>
284289
<p>Element-wise conditional selection: <code>seriesWhere</code> / <code>seriesMask</code> and <code>dataFrameWhere</code> / <code>dataFrameMask</code>. Accepts boolean arrays, label-aligned boolean Series/DataFrame, or callables. Mirrors <code>pandas.Series.where</code>, <code>pandas.DataFrame.where</code>, and their <code>.mask()</code> inverses.</p>

playground/rolling_apply.html

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>tsb — Rolling Apply &amp; Multi-Aggregation</title>
7+
<style>
8+
body {
9+
font-family: system-ui, sans-serif;
10+
max-width: 860px;
11+
margin: 2rem auto;
12+
padding: 0 1rem;
13+
line-height: 1.6;
14+
color: #1a1a1a;
15+
}
16+
h1 { color: #0d47a1; }
17+
h2 { color: #1565c0; border-bottom: 2px solid #e3f2fd; padding-bottom: 0.25rem; }
18+
pre {
19+
background: #f5f5f5;
20+
border-left: 4px solid #0d47a1;
21+
padding: 1rem;
22+
overflow-x: auto;
23+
border-radius: 4px;
24+
}
25+
code { font-family: "Fira Code", "Cascadia Code", monospace; font-size: 0.9em; }
26+
.demo {
27+
background: #e8f5e9;
28+
border: 1px solid #a5d6a7;
29+
border-radius: 6px;
30+
padding: 1rem 1.25rem;
31+
margin: 1rem 0;
32+
}
33+
.demo h3 { margin-top: 0; color: #2e7d32; }
34+
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
35+
th, td { border: 1px solid #ccc; padding: 0.4rem 0.75rem; text-align: left; }
36+
th { background: #e3f2fd; }
37+
.note { background: #fff9c4; border: 1px solid #f9a825; border-radius: 4px; padding: 0.75rem 1rem; }
38+
a { color: #0d47a1; }
39+
</style>
40+
</head>
41+
<body>
42+
<h1>tsb — Rolling Apply &amp; Multi-Aggregation</h1>
43+
<p>
44+
Standalone functions for applying custom aggregation logic over sliding
45+
windows, mirroring
46+
<a href="https://pandas.pydata.org/docs/reference/api/pandas.core.window.rolling.Rolling.apply.html">
47+
<code>pandas.Series.rolling().apply()</code>
48+
</a>
49+
and
50+
<a href="https://pandas.pydata.org/docs/reference/api/pandas.core.window.rolling.Rolling.agg.html">
51+
<code>Rolling.agg()</code>
52+
</a>.
53+
</p>
54+
55+
<h2>1. <code>rollingApply</code> — Custom Function Per Window</h2>
56+
<p>
57+
Apply any aggregation function to each rolling window. The function
58+
receives the <strong>valid (non-null, non-NaN) numeric values</strong>
59+
in the window and must return a single number.
60+
</p>
61+
<pre><code>import { rollingApply } from "tsb";
62+
63+
const prices = new Series({ data: [10, 12, 11, 15, 14, 16], name: "price" });
64+
65+
// Custom: range (max - min) over each 3-day window
66+
const range = (w) =&gt; Math.max(...w) - Math.min(...w);
67+
68+
rollingApply(prices, 3, range).toArray();
69+
// [null, null, 2, 4, 4, 5]
70+
// ↑↑ insufficient data (need 3 observations)</code></pre>
71+
72+
<div class="demo">
73+
<h3>Options</h3>
74+
<table>
75+
<thead>
76+
<tr><th>Option</th><th>Default</th><th>Description</th></tr>
77+
</thead>
78+
<tbody>
79+
<tr><td><code>minPeriods</code></td><td><code>window</code></td><td>Minimum valid observations to compute (null otherwise)</td></tr>
80+
<tr><td><code>center</code></td><td><code>false</code></td><td>Centre the window (symmetric) instead of trailing</td></tr>
81+
<tr><td><code>raw</code></td><td><code>false</code></td><td>Pass full window including nulls (filtered to valid nums before fn call)</td></tr>
82+
</tbody>
83+
</table>
84+
</div>
85+
86+
<pre><code>// minPeriods=1 → start computing from the very first position
87+
rollingApply(prices, 3, range, { minPeriods: 1 }).toArray();
88+
// [0, 2, 2, 4, 4, 5]
89+
90+
// center=true → symmetric window around each point
91+
rollingApply(prices, 3, range, { center: true }).toArray();
92+
// [null, 2, 4, 4, 5, null]</code></pre>
93+
94+
<h2>2. <code>rollingAgg</code> — Multiple Aggregations at Once</h2>
95+
<p>
96+
Apply several named aggregation functions in a single pass over a Series,
97+
returning a <code>DataFrame</code> where each column holds one
98+
aggregation result.
99+
</p>
100+
<pre><code>import { rollingAgg } from "tsb";
101+
102+
const s = new Series({ data: [1, 2, 3, 4, 5, 6, 7, 8] });
103+
104+
const result = rollingAgg(s, 3, {
105+
mean: (w) =&gt; w.reduce((a, b) =&gt; a + b, 0) / w.length,
106+
max: (w) =&gt; Math.max(...w),
107+
min: (w) =&gt; Math.min(...w),
108+
range:(w) =&gt; Math.max(...w) - Math.min(...w),
109+
});
110+
111+
// result is a DataFrame with columns: "mean", "max", "min", "range"
112+
// result.col("mean").toArray() → [null, null, 2, 3, 4, 5, 6, 7]
113+
// result.col("range").toArray() → [null, null, 2, 2, 2, 2, 2, 2]</code></pre>
114+
115+
<div class="note">
116+
<strong>Pandas equivalent:</strong><br />
117+
<code>s.rolling(3).agg({"mean": np.mean, "max": np.max, "min": np.min})</code>
118+
</div>
119+
120+
<h2>3. <code>dataFrameRollingApply</code> — Apply Per Column</h2>
121+
<p>
122+
Apply a single custom function independently to each column of a
123+
DataFrame, returning a new DataFrame of the same shape.
124+
</p>
125+
<pre><code>import { dataFrameRollingApply } from "tsb";
126+
127+
const df = DataFrame.fromColumns({
128+
open: [100, 102, 101, 105, 103],
129+
close: [101, 103, 100, 106, 104],
130+
});
131+
132+
// Pairwise range within each 2-step window per column
133+
const range = (w) =&gt; Math.max(...w) - Math.min(...w);
134+
135+
dataFrameRollingApply(df, 2, range);
136+
// open close
137+
// 0 null null
138+
// 1 2 2
139+
// 2 1 3
140+
// 3 4 6
141+
// 4 2 2</code></pre>
142+
143+
<h2>4. <code>dataFrameRollingAgg</code> — Multi-Agg Per Column</h2>
144+
<p>
145+
Apply multiple named aggregation functions to every column of a
146+
DataFrame. The result has columns named
147+
<code>{originalColumn}_{aggName}</code>.
148+
</p>
149+
<pre><code>import { dataFrameRollingAgg } from "tsb";
150+
151+
const df = DataFrame.fromColumns({
152+
A: [1, 2, 3, 4, 5],
153+
B: [10, 20, 30, 40, 50],
154+
});
155+
156+
const out = dataFrameRollingAgg(df, 3, {
157+
sum: (w) =&gt; w.reduce((a, b) =&gt; a + b, 0),
158+
mean: (w) =&gt; w.reduce((a, b) =&gt; a + b, 0) / w.length,
159+
});
160+
161+
// Columns: "A_sum", "A_mean", "B_sum", "B_mean"
162+
// A_sum: [null, null, 6, 9, 12]
163+
// A_mean: [null, null, 2, 3, 4]
164+
// B_sum: [null, null, 60, 90, 120]
165+
// B_mean: [null, null, 20, 30, 40]</code></pre>
166+
167+
<h2>Comparison with pandas</h2>
168+
<table>
169+
<thead>
170+
<tr><th>tsb</th><th>pandas</th></tr>
171+
</thead>
172+
<tbody>
173+
<tr>
174+
<td><code>rollingApply(s, w, fn)</code></td>
175+
<td><code>s.rolling(w).apply(fn, raw=True)</code></td>
176+
</tr>
177+
<tr>
178+
<td><code>rollingApply(s, w, fn, {minPeriods:1})</code></td>
179+
<td><code>s.rolling(w, min_periods=1).apply(fn)</code></td>
180+
</tr>
181+
<tr>
182+
<td><code>rollingAgg(s, w, {f1, f2})</code></td>
183+
<td><code>s.rolling(w).agg({"f1": f1, "f2": f2})</code></td>
184+
</tr>
185+
<tr>
186+
<td><code>dataFrameRollingApply(df, w, fn)</code></td>
187+
<td><code>df.rolling(w).apply(fn)</code></td>
188+
</tr>
189+
<tr>
190+
<td><code>dataFrameRollingAgg(df, w, {f1, f2})</code></td>
191+
<td><code>df.rolling(w).agg({"f1": f1, "f2": f2})</code></td>
192+
</tr>
193+
</tbody>
194+
</table>
195+
196+
<h2>Use case: Bollinger Band width</h2>
197+
<pre><code>import { rollingAgg } from "tsb";
198+
199+
// Bollinger Band width = (upper - lower) / middle
200+
// where upper = mean + 2·std, lower = mean - 2·std
201+
const prices = new Series({
202+
data: [20, 21, 22, 20, 19, 21, 23, 24, 22, 21],
203+
name: "price",
204+
});
205+
206+
const stats = rollingAgg(prices, 5, {
207+
mean: (w) =&gt; w.reduce((a, b) =&gt; a + b, 0) / w.length,
208+
std: (w) =&gt; {
209+
const m = w.reduce((a, b) =&gt; a + b, 0) / w.length;
210+
return Math.sqrt(w.reduce((a, b) =&gt; a + (b - m) ** 2, 0) / (w.length - 1));
211+
},
212+
});
213+
214+
// Bollinger Band width = 4 * std / mean
215+
const bw = stats.col("std").toArray().map((std, i) =&gt; {
216+
const mean = stats.col("mean").toArray()[i];
217+
if (std === null || mean === null || mean === 0) return null;
218+
return (4 * (std as number)) / (mean as number);
219+
});</code></pre>
220+
221+
<p>
222+
<a href="index.html">← Back to tsb playground index</a>
223+
</p>
224+
</body>
225+
</html>

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ export type { ExpandingOptions, ExpandingSeriesLike } from "./window/index.ts";
6161
export { DataFrameExpanding } from "./core/index.ts";
6262
export { EWM } from "./window/index.ts";
6363
export type { EwmOptions, EwmSeriesLike } from "./window/index.ts";
64+
export {
65+
rollingApply,
66+
rollingAgg,
67+
dataFrameRollingApply,
68+
dataFrameRollingAgg,
69+
} from "./window/index.ts";
70+
export type { RollingApplyOptions, RollingAggOptions, AggFunctions } from "./window/index.ts";
6471
export { DataFrameEwm } from "./core/index.ts";
6572
export { CategoricalAccessor } from "./core/index.ts";
6673
export type { CatSeriesLike } from "./core/index.ts";

src/window/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ export { Expanding } from "./expanding.ts";
1010
export type { ExpandingOptions, ExpandingSeriesLike } from "./expanding.ts";
1111
export { EWM } from "./ewm.ts";
1212
export type { EwmOptions, EwmSeriesLike } from "./ewm.ts";
13+
export {
14+
rollingApply,
15+
rollingAgg,
16+
dataFrameRollingApply,
17+
dataFrameRollingAgg,
18+
} from "./rolling_apply.ts";
19+
export type { RollingApplyOptions, RollingAggOptions, AggFunctions } from "./rolling_apply.ts";

0 commit comments

Comments
 (0)