packages/core/src/features/viz/lib/financial-oscillators.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 * 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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.