Skip to content

packages/core/src/features/viz/lib/financial-oscillators.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  * Shared inputs for source-aligned closing-price oscillators.
6  *
7  * @see {@link RelativeStrengthIndexPoint}
8  * @see {@link MovingAverageConvergenceDivergencePoint}
9  */
10 export interface OscillatorOptions<D> {
11   /**
12    * Numeric property or callback that supplies each closing observation.
13    *
14    * @see {@link LineAccessor}
15    */
16   readonly value: LineAccessor<D>;
17   /**
18    * Excludes a row before its accessor runs and resets the current warmup.
19    *
20    * @param datum - Source observation under consideration.
21    * @param index - Zero-based source index.
22    * @param data - Complete caller-supplied source data.
23    * @returns Whether the observation participates in the consecutive calculation.
24    * @see {@link LineAccessor}
25    */
26   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
27 }
28 
29 /**
30  * Inputs for Wilder's relative strength index.
31  *
32  * @see {@link OscillatorOptions}
33  * @see {@link RelativeStrengthIndexPoint}
34  */
35 export interface RelativeStrengthIndexOptions<D> extends OscillatorOptions<D> {
36   /**
37    * Positive safe number of consecutive price changes used by Wilder smoothing.
38    *
39    * @see {@link relativeStrengthIndex}
40    */
41   readonly period: number;
42 }
43 
44 /**
45  * Inputs for moving average convergence/divergence.
46  *
47  * @see {@link OscillatorOptions}
48  * @see {@link MovingAverageConvergenceDivergencePoint}
49  */
50 export interface MovingAverageConvergenceDivergenceOptions<
51   D,
52 > extends OscillatorOptions<D> {
53   /** Positive safe EMA period shorter than slowPeriod. */
54   readonly fastPeriod: number;
55   /** Positive safe EMA period longer than fastPeriod. */
56   readonly slowPeriod: number;
57   /** Positive safe EMA period used to smooth available MACD observations. */
58   readonly signalPeriod: number;
59 }
60 
61 /**
62  * Inputs for the rolling stochastic oscillator.
63  *
64  * @see {@link StochasticOscillatorPoint}
65  * @see {@link LineAccessor}
66  */
67 export interface StochasticOscillatorOptions<D> {
68   /** Numeric property or callback that supplies each high. See {@link LineAccessor}. */
69   readonly high: LineAccessor<D>;
70   /** Numeric property or callback that supplies each low. See {@link LineAccessor}. */
71   readonly low: LineAccessor<D>;
72   /** Numeric property or callback that supplies each close. See {@link LineAccessor}. */
73   readonly close: LineAccessor<D>;
74   /** Positive safe count of consecutive OHLC rows in the raw %K range. */
75   readonly lookbackPeriod: number;
76   /** Positive safe count of raw %K values in the smoothed %K average. */
77   readonly kPeriod: number;
78   /** Positive safe count of smoothed %K values in the %D average. */
79   readonly dPeriod: number;
80   /**
81    * Excludes a row before its accessors run and resets all oscillator warmup.
82    *
83    * @param datum - Source observation under consideration.
84    * @param index - Zero-based source index.
85    * @param data - Complete caller-supplied source data.
86    * @returns Whether the observation participates in consecutive windows.
87    * @see {@link LineAccessor}
88    */
89   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
90 }
91 
92 /**
93  * One source-aligned relative strength index observation.
94  *
95  * @see {@link RelativeStrengthIndexOptions}
96  */
97 export interface RelativeStrengthIndexPoint<D> {
98   /** Caller-owned original observation. */
99   readonly datum: D;
100   /** Original source index, including rows that become gaps. */
101   readonly sourceIndex: number;
102   /** Wilder RSI from zero through 100, or null during warmup and gaps. */
103   readonly rsi: number | null;
104 }
105 
106 /**
107  * One source-aligned moving average convergence/divergence observation.
108  *
109  * @see {@link MovingAverageConvergenceDivergenceOptions}
110  */
111 export interface MovingAverageConvergenceDivergencePoint<D> {
112   /** Caller-owned original observation. */
113   readonly datum: D;
114   /** Original source index, including rows that become gaps. */
115   readonly sourceIndex: number;
116   /** Fast EMA minus slow EMA, or null before both are seeded. */
117   readonly macd: number | null;
118   /** EMA of available MACD values, or null during signal warmup. */
119   readonly signal: number | null;
120   /** MACD minus its signal, or null before the signal is available. */
121   readonly histogram: number | null;
122 }
123 
124 /**
125  * One source-aligned stochastic oscillator observation.
126  *
127  * @see {@link StochasticOscillatorOptions}
128  */
129 export interface StochasticOscillatorPoint<D> {
130   /** Caller-owned original observation. */
131   readonly datum: D;
132   /** Original source index, including rows that become gaps. */
133   readonly sourceIndex: number;
134   /** Unsmoothed %K from zero through 100, or null during warmup/a zero range. */
135   readonly rawK: number | null;
136   /** SMA-smoothed %K, or null during warmup/a zero range. */
137   readonly k: number | null;
138   /** SMA of smoothed %K, or null during warmup/a zero range. */
139   readonly d: number | null;
140 }
141 
142 function positivePeriod(period: number, name: string): void {
143   if (!Number.isSafeInteger(period) || period <= 0)
144     throw new RangeError(`${name} must be a positive safe integer.`);
145 }
146 
147 function mean(values: readonly number[]): number {
148   let scale = 0;
149   for (const value of values) scale = Math.max(scale, Math.abs(value));
150   if (scale === 0) return 0;
151   let sum = 0;
152   for (const value of values) sum += value / scale;
153   const result = (sum / values.length) * scale;
154   if (!Number.isFinite(result))
155     throw new RangeError("oscillator derived value must be finite.");
156   return result;
157 }
158 
159 function weighted(previous: number, next: number, alpha: number): number {
160   const scale = Math.max(Math.abs(previous), Math.abs(next));
161   if (scale === 0) return 0;
162   const result =
163     ((previous / scale) * (1 - alpha) + (next / scale) * alpha) * scale;
164   if (!Number.isFinite(result))
165     throw new RangeError("oscillator derived value must be finite.");
166   return result;
167 }
168 
169 function rollingPush(values: number[], value: number, period: number): void {
170   values.push(value);
171   if (values.length > period) values.shift();
172 }
173 
174 function oscillatorValues<D>(
175   data: readonly D[],
176   options: OscillatorOptions<D>,
177 ): readonly (number | null)[] {
178   const rows = pointData(data, "oscillator");
179   const value = pointAccessor(options.value);
180   return Object.freeze(
181     rows.map((datum, index) => {
182       if (!(options.defined?.(datum, index, rows) ?? true)) return null;
183       const observed = value(datum, index, rows);
184       return finiteCoordinate(observed) ? observed : null;
185     }),
186   );
187 }
188 
189 /**
190  * Derives source-aligned Wilder relative strength index observations.
191  *
192  * The first RSI follows a full `period` of consecutive price changes, requiring `period + 1`
193  * consecutive prices. Flat average gain/loss yields 50; gain-only yields 100 and loss-only yields
194  * 0. Missing, nonfinite, and excluded rows produce null and reset the seed.
195  *
196  * @param data - Source observations, retained in their original order.
197  * @param options - Closing value accessor, Wilder period, and optional participation callback.
198  * @returns Frozen source-aligned RSI observations.
199  * @see {@link RelativeStrengthIndexOptions}
200  * @see {@link RelativeStrengthIndexPoint}
201  */
202 export function relativeStrengthIndex<D>(
203   data: readonly D[],
204   options: RelativeStrengthIndexOptions<D>,
205 ): readonly RelativeStrengthIndexPoint<D>[] {
206   positivePeriod(options.period, "RSI period");
207   const values = oscillatorValues(data, options);
208   const gains: number[] = [],
209     losses: number[] = [];
210   let previous: number | undefined;
211   let averageGain: number | undefined, averageLoss: number | undefined;
212   return Object.freeze(
213     data.map((datum, sourceIndex) => {
214       const value = values[sourceIndex];
215       if (value === null) {
216         gains.length = 0;
217         losses.length = 0;
218         previous = undefined;
219         averageGain = undefined;
220         averageLoss = undefined;
221         return Object.freeze({ datum, sourceIndex, rsi: null });
222       }
223       if (previous === undefined) {
224         previous = value;
225         return Object.freeze({ datum, sourceIndex, rsi: null });
226       }
227       const change = value - previous;
228       if (!Number.isFinite(change))
229         throw new RangeError("RSI change must be finite.");
230       previous = value;
231       const gain = Math.max(change, 0),
232         loss = Math.max(-change, 0);
233       if (averageGain === undefined || averageLoss === undefined) {
234         gains.push(gain);
235         losses.push(loss);
236         if (gains.length < options.period)
237           return Object.freeze({ datum, sourceIndex, rsi: null });
238         averageGain = mean(gains);
239         averageLoss = mean(losses);
240       } else {
241         averageGain = weighted(averageGain, gain, 1 / options.period);
242         averageLoss = weighted(averageLoss, loss, 1 / options.period);
243       }
244       const rsi =
245         averageGain === 0 && averageLoss === 0
246           ? 50
247           : averageLoss === 0
248             ? 100
249             : averageGain === 0
250               ? 0
251               : 100 - 100 / (1 + averageGain / averageLoss);
252       if (!Number.isFinite(rsi))
253         throw new RangeError("RSI derived value must be finite.");
254       return Object.freeze({ datum, sourceIndex, rsi });
255     }),
256   );
257 }
258 
259 /**
260  * Derives source-aligned MACD, signal, and histogram observations from SMA-seeded EMAs.
261  *
262  * Missing, nonfinite, and excluded rows produce null fields and reset all EMA warmup. The MACD
263  * value appears once both price EMAs are seeded; signal and histogram wait for their own EMA seed.
264  *
265  * @param data - Source observations, retained in their original order.
266  * @param options - Closing value accessor, ordered EMA periods, and participation callback.
267  * @returns Frozen source-aligned MACD observations.
268  * @see {@link MovingAverageConvergenceDivergenceOptions}
269  * @see {@link MovingAverageConvergenceDivergencePoint}
270  */
271 export function movingAverageConvergenceDivergence<D>(
272   data: readonly D[],
273   options: MovingAverageConvergenceDivergenceOptions<D>,
274 ): readonly MovingAverageConvergenceDivergencePoint<D>[] {
275   positivePeriod(options.fastPeriod, "MACD fastPeriod");
276   positivePeriod(options.slowPeriod, "MACD slowPeriod");
277   positivePeriod(options.signalPeriod, "MACD signalPeriod");
278   if (options.fastPeriod >= options.slowPeriod)
279     throw new RangeError("MACD fastPeriod must be less than slowPeriod.");
280   const values = oscillatorValues(data, options);
281   const fastSeed: number[] = [],
282     slowSeed: number[] = [],
283     signalSeed: number[] = [];
284   const fastAlpha = 2 / (options.fastPeriod + 1),
285     slowAlpha = 2 / (options.slowPeriod + 1);
286   const signalAlpha = 2 / (options.signalPeriod + 1);
287   let fast: number | undefined,
288     slow: number | undefined,
289     signal: number | undefined;
290   const reset = () => {
291     fastSeed.length = 0;
292     slowSeed.length = 0;
293     signalSeed.length = 0;
294     fast = undefined;
295     slow = undefined;
296     signal = undefined;
297   };
298   return Object.freeze(
299     data.map((datum, sourceIndex) => {
300       const value = values[sourceIndex];
301       if (value === null) {
302         reset();
303         return Object.freeze({
304           datum,
305           sourceIndex,
306           macd: null,
307           signal: null,
308           histogram: null,
309         });
310       }
311       if (fast === undefined) {
312         fastSeed.push(value);
313         if (fastSeed.length === options.fastPeriod) fast = mean(fastSeed);
314       } else fast = weighted(fast, value, fastAlpha);
315       if (slow === undefined) {
316         slowSeed.push(value);
317         if (slowSeed.length === options.slowPeriod) slow = mean(slowSeed);
318       } else slow = weighted(slow, value, slowAlpha);
319       if (fast === undefined || slow === undefined)
320         return Object.freeze({
321           datum,
322           sourceIndex,
323           macd: null,
324           signal: null,
325           histogram: null,
326         });
327       const macd = fast - slow;
328       if (!Number.isFinite(macd))
329         throw new RangeError("MACD derived value must be finite.");
330       if (signal === undefined) {
331         signalSeed.push(macd);
332         if (signalSeed.length === options.signalPeriod)
333           signal = mean(signalSeed);
334       } else signal = weighted(signal, macd, signalAlpha);
335       const histogram = signal === undefined ? null : macd - signal;
336       if (histogram !== null && !Number.isFinite(histogram))
337         throw new RangeError("MACD histogram must be finite.");
338       return Object.freeze({
339         datum,
340         sourceIndex,
341         macd,
342         signal: signal ?? null,
343         histogram,
344       });
345     }),
346   );
347 }
348 
349 interface OhlcValue {
350   readonly high: number;
351   readonly low: number;
352   readonly close: number;
353 }
354 
355 function stochasticValues<D>(
356   data: readonly D[],
357   options: StochasticOscillatorOptions<D>,
358 ): readonly (OhlcValue | null)[] {
359   const rows = pointData(data, "stochastic oscillator");
360   const high = pointAccessor(options.high),
361     low = pointAccessor(options.low),
362     close = pointAccessor(options.close);
363   return Object.freeze(
364     rows.map((datum, index) => {
365       if (!(options.defined?.(datum, index, rows) ?? true)) return null;
366       const highValue = high(datum, index, rows);
367       const lowValue = low(datum, index, rows);
368       const closeValue = close(datum, index, rows);
369       if (
370         !finiteCoordinate(highValue) ||
371         !finiteCoordinate(lowValue) ||
372         !finiteCoordinate(closeValue)
373       )
374         return null;
375       const values: OhlcValue = {
376         high: highValue,
377         low: lowValue,
378         close: closeValue,
379       };
380       if (
381         values.low > values.high ||
382         values.close < values.low ||
383         values.close > values.high
384       )
385         throw new RangeError(
386           "stochastic oscillator requires low <= close <= high.",
387         );
388       return Object.freeze(values);
389     }),
390   );
391 }
392 
393 /**
394  * Derives source-aligned rolling stochastic %K, smoothed %K, and %D observations.
395  *
396  * %K uses the trailing high/low range. A zero range yields null raw %K and resets only the %K/%D
397  * smoothing warmup, while its complete OHLC row remains in the rolling range. Missing, nonfinite,
398  * and excluded rows reset every window; malformed finite candles reject.
399  *
400  * @param data - Source OHLC observations, retained in their original order.
401  * @param options - OHLC accessors, rolling periods, and optional participation callback.
402  * @returns Frozen source-aligned stochastic oscillator observations.
403  * @see {@link StochasticOscillatorOptions}
404  * @see {@link StochasticOscillatorPoint}
405  */
406 export function stochasticOscillator<D>(
407   data: readonly D[],
408   options: StochasticOscillatorOptions<D>,
409 ): readonly StochasticOscillatorPoint<D>[] {
410   positivePeriod(options.lookbackPeriod, "stochastic lookbackPeriod");
411   positivePeriod(options.kPeriod, "stochastic kPeriod");
412   positivePeriod(options.dPeriod, "stochastic dPeriod");
413   const values = stochasticValues(data, options);
414   const window: OhlcValue[] = [],
415     kValues: number[] = [],
416     dValues: number[] = [];
417   const reset = () => {
418     window.length = 0;
419     kValues.length = 0;
420     dValues.length = 0;
421   };
422   const resetSmoothing = () => {
423     kValues.length = 0;
424     dValues.length = 0;
425   };
426   return Object.freeze(
427     data.map((datum, sourceIndex) => {
428       const value = values[sourceIndex];
429       if (value === null) {
430         reset();
431         return Object.freeze({
432           datum,
433           sourceIndex,
434           rawK: null,
435           k: null,
436           d: null,
437         });
438       }
439       window.push(value);
440       if (window.length > options.lookbackPeriod) window.shift();
441       if (window.length < options.lookbackPeriod)
442         return Object.freeze({
443           datum,
444           sourceIndex,
445           rawK: null,
446           k: null,
447           d: null,
448         });
449       let high = -Infinity,
450         low = Infinity;
451       for (const row of window) {
452         high = Math.max(high, row.high);
453         low = Math.min(low, row.low);
454       }
455       const scale = Math.max(
456         Math.abs(high),
457         Math.abs(low),
458         Math.abs(value.close),
459       );
460       if (scale === 0) {
461         resetSmoothing();
462         return Object.freeze({
463           datum,
464           sourceIndex,
465           rawK: null,
466           k: null,
467           d: null,
468         });
469       }
470       const scaledLow = low / scale;
471       const scaledRange = high / scale - scaledLow;
472       if (scaledRange === 0) {
473         resetSmoothing();
474         return Object.freeze({
475           datum,
476           sourceIndex,
477           rawK: null,
478           k: null,
479           d: null,
480         });
481       }
482       const rawK = ((value.close / scale - scaledLow) / scaledRange) * 100;
483       if (!Number.isFinite(rawK))
484         throw new RangeError("stochastic %K must be finite.");
485       rollingPush(kValues, rawK, options.kPeriod);
486       const k = kValues.length === options.kPeriod ? mean(kValues) : null;
487       if (k === null)
488         return Object.freeze({ datum, sourceIndex, rawK, k: null, d: null });
489       rollingPush(dValues, k, options.dPeriod);
490       const d = dValues.length === options.dPeriod ? mean(dValues) : null;
491       return Object.freeze({ datum, sourceIndex, rawK, k, d });
492     }),
493   );
494 }
495 

Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.