|
| 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 & 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 & 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) => 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) => w.reduce((a, b) => a + b, 0) / w.length, |
| 106 | + max: (w) => Math.max(...w), |
| 107 | + min: (w) => Math.min(...w), |
| 108 | + range:(w) => 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) => 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) => w.reduce((a, b) => a + b, 0), |
| 158 | + mean: (w) => w.reduce((a, b) => 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) => w.reduce((a, b) => a + b, 0) / w.length, |
| 208 | + std: (w) => { |
| 209 | + const m = w.reduce((a, b) => a + b, 0) / w.length; |
| 210 | + return Math.sqrt(w.reduce((a, b) => 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) => { |
| 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> |
0 commit comments