packages/core/src/features/viz/lib/financial-indicators.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import type { LineAccessor } from "./types.js";
2 import { finiteCoordinate, pointAccessor, pointData } from "./point-data.js";
3
4 /**
5 * Inputs shared by source-aligned moving-average indicators.
6 *
7 * @see {@link MovingAveragePoint}
8 * @see {@link LineAccessor}
9 */
10 export interface MovingAverageOptions<D> {
11 /**
12 * Numeric property or callback that supplies each observation.
13 *
14 * @see {@link LineAccessor}
15 */
16 readonly value: LineAccessor<D>;
17 /**
18 * Required number of consecutive complete observations in each window.
19 *
20 * @see {@link MovingAveragePoint}
21 */
22 readonly period: number;
23 /**
24 * Excludes a row before its value accessor runs and resets the current window.
25 *
26 * @param datum - Source observation under consideration.
27 * @param index - Zero-based source index.
28 * @param data - Complete caller-supplied source data.
29 * @returns Whether the observation participates in the current consecutive window.
30 * @see {@link LineAccessor}
31 */
32 readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
33 }
34
35 /**
36 * Inputs for source-aligned population-standard-deviation Bollinger bands.
37 *
38 * @see {@link MovingAverageOptions}
39 * @see {@link BollingerBandPoint}
40 */
41 export interface BollingerBandsOptions<D> extends MovingAverageOptions<D> {
42 /**
43 * Number of population standard deviations from the mean; defaults to 2.
44 *
45 * @see {@link BollingerBandPoint}
46 */
47 readonly multiplier?: number;
48 }
49
50 /**
51 * One source-aligned simple or exponential moving-average observation.
52 *
53 * @see {@link MovingAverageOptions}
54 */
55 export interface MovingAveragePoint<D> {
56 /** Caller-owned original observation. */
57 readonly datum: D;
58 /** Original source index, including rows that become gaps. */
59 readonly sourceIndex: number;
60 /** Average after a complete consecutive window, or null during warmup/a gap. */
61 readonly average: number | null;
62 }
63
64 /**
65 * One source-aligned population-standard-deviation Bollinger-band observation.
66 *
67 * @see {@link BollingerBandsOptions}
68 */
69 export interface BollingerBandPoint<D> {
70 /** Caller-owned original observation. */
71 readonly datum: D;
72 /** Original source index, including rows that become gaps. */
73 readonly sourceIndex: number;
74 /** Window mean, or null during warmup/a gap. */
75 readonly mean: number | null;
76 /** Mean minus multiplier population standard deviations, or null during warmup/a gap. */
77 readonly lower: number | null;
78 /** Mean plus multiplier population standard deviations, or null during warmup/a gap. */
79 readonly upper: number | null;
80 }
81
82 function validatePeriod(period: number): void {
83 if (!Number.isSafeInteger(period) || period <= 0)
84 throw new RangeError(
85 "moving-average period must be a positive safe integer.",
86 );
87 }
88
89 function validateMultiplier(multiplier: number): void {
90 if (!Number.isFinite(multiplier) || multiplier < 0)
91 throw new RangeError(
92 "Bollinger multiplier must be finite and non-negative.",
93 );
94 }
95
96 function values<D>(
97 data: readonly D[],
98 options: MovingAverageOptions<D>,
99 ): readonly (number | null)[] {
100 const rows = pointData(data, "moving average");
101 validatePeriod(options.period);
102 const value = pointAccessor(options.value);
103 return Object.freeze(
104 rows.map((datum, index) => {
105 if (!(options.defined?.(datum, index, rows) ?? true)) return null;
106 const observed = value(datum, index, rows);
107 return finiteCoordinate(observed) ? observed : null;
108 }),
109 );
110 }
111
112 function summary(window: readonly number[]): readonly [number, number] {
113 let scale = 0;
114 for (const value of window) scale = Math.max(scale, Math.abs(value));
115 if (scale === 0) return [0, 0];
116 let normalizedSum = 0;
117 for (const value of window) normalizedSum += value / scale;
118 const meanNormalized = normalizedSum / window.length;
119 let squaredDifferenceSum = 0;
120 for (const value of window) {
121 const difference = value / scale - meanNormalized;
122 squaredDifferenceSum += difference * difference;
123 }
124 const varianceNormalized = squaredDifferenceSum / window.length;
125 const mean = meanNormalized * scale;
126 const deviation = Math.sqrt(varianceNormalized) * scale;
127 if (!Number.isFinite(mean) || !Number.isFinite(deviation))
128 throw new RangeError("moving-average derived value must be finite.");
129 return [mean, deviation];
130 }
131
132 function weighted(previous: number, next: number, alpha: number): number {
133 const scale = Math.max(Math.abs(previous), Math.abs(next));
134 if (scale === 0) return 0;
135 const result =
136 ((previous / scale) * (1 - alpha) + (next / scale) * alpha) * scale;
137 if (!Number.isFinite(result))
138 throw new RangeError("moving-average derived value must be finite.");
139 return result;
140 }
141
142 /**
143 * Derives a source-aligned simple moving average from consecutive finite observations.
144 *
145 * Missing, nonfinite, and excluded observations produce null and reset warmup. Results are
146 * population-neutral arithmetic means; no timestamps are inferred or changed.
147 *
148 * @param data - Source observations, retained in their original order.
149 * @param options - Value accessor, consecutive period, and optional participation callback.
150 * @returns Frozen source-aligned average observations, with null during warmup and gaps.
151 * @see {@link MovingAverageOptions}
152 * @see {@link MovingAveragePoint}
153 */
154 export function simpleMovingAverage<D>(
155 data: readonly D[],
156 options: MovingAverageOptions<D>,
157 ): readonly MovingAveragePoint<D>[] {
158 const observed = values(data, options);
159 const window: number[] = [];
160 return Object.freeze(
161 data.map((datum, sourceIndex) => {
162 const value = observed[sourceIndex];
163 if (value === null) {
164 window.length = 0;
165 return Object.freeze({ datum, sourceIndex, average: null });
166 }
167 window.push(value);
168 if (window.length > options.period) window.shift();
169 const average =
170 window.length === options.period ? summary(window)[0] : null;
171 return Object.freeze({ datum, sourceIndex, average });
172 }),
173 );
174 }
175
176 /**
177 * Derives a source-aligned exponential moving average seeded by the first complete SMA window.
178 *
179 * Its smoothing factor is `2 / (period + 1)`. Missing, nonfinite, and excluded observations
180 * produce null and reset the seed window.
181 *
182 * @param data - Source observations, retained in their original order.
183 * @param options - Value accessor, consecutive seed period, and optional participation callback.
184 * @returns Frozen source-aligned average observations, with null during warmup and gaps.
185 * @see {@link MovingAverageOptions}
186 * @see {@link MovingAveragePoint}
187 */
188 export function exponentialMovingAverage<D>(
189 data: readonly D[],
190 options: MovingAverageOptions<D>,
191 ): readonly MovingAveragePoint<D>[] {
192 const observed = values(data, options);
193 const seed: number[] = [];
194 const alpha = 2 / (options.period + 1);
195 let average: number | undefined;
196 return Object.freeze(
197 data.map((datum, sourceIndex) => {
198 const value = observed[sourceIndex];
199 if (value === null) {
200 seed.length = 0;
201 average = undefined;
202 return Object.freeze({ datum, sourceIndex, average: null });
203 }
204 if (average === undefined) {
205 seed.push(value);
206 if (seed.length < options.period)
207 return Object.freeze({ datum, sourceIndex, average: null });
208 average = summary(seed)[0];
209 } else average = weighted(average, value, alpha);
210 return Object.freeze({ datum, sourceIndex, average });
211 }),
212 );
213 }
214
215 /**
216 * Derives source-aligned Bollinger bands from trailing consecutive population-standard-deviation windows.
217 *
218 * Missing, nonfinite, and excluded observations produce null bands and reset warmup. The multiplier
219 * defaults to 2; caller-owned data are neither sorted nor mutated.
220 *
221 * @param data - Source observations, retained in their original order.
222 * @param options - Value accessor, period, participation callback, and optional band multiplier.
223 * @returns Frozen source-aligned population-standard-deviation band observations.
224 * @see {@link BollingerBandsOptions}
225 * @see {@link BollingerBandPoint}
226 */
227 export function bollingerBands<D>(
228 data: readonly D[],
229 options: BollingerBandsOptions<D>,
230 ): readonly BollingerBandPoint<D>[] {
231 const multiplier = options.multiplier ?? 2;
232 validateMultiplier(multiplier);
233 const observed = values(data, options);
234 const window: number[] = [];
235 return Object.freeze(
236 data.map((datum, sourceIndex) => {
237 const value = observed[sourceIndex];
238 if (value === null) {
239 window.length = 0;
240 return Object.freeze({
241 datum,
242 sourceIndex,
243 mean: null,
244 lower: null,
245 upper: null,
246 });
247 }
248 window.push(value);
249 if (window.length > options.period) window.shift();
250 if (window.length < options.period)
251 return Object.freeze({
252 datum,
253 sourceIndex,
254 mean: null,
255 lower: null,
256 upper: null,
257 });
258 const [mean, deviation] = summary(window);
259 const lower = mean - multiplier * deviation;
260 const upper = mean + multiplier * deviation;
261 if (!Number.isFinite(lower) || !Number.isFinite(upper))
262 throw new RangeError("Bollinger derived value must be finite.");
263 return Object.freeze({ datum, sourceIndex, mean, lower, upper });
264 }),
265 );
266 }
267
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.