Skip to content

packages/core/src/features/viz/lib/financial-indicators.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.