Skip to content

packages/core/src/features/viz/lib/financial-comparison.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 /** Inputs for a fixed-base financial comparison. See {@link percentageReturns}. */
5 export interface ComparisonBaseOptions<D> {
6   /** UTC-millisecond property or callback that identifies the common base observation. */
7   readonly time: LineAccessor<D>;
8   /** Numeric property or callback that supplies each instrument's observed value. */
9   readonly value: LineAccessor<D>;
10   /** Safe UTC-millisecond timestamp that must match exactly one eligible complete observation. */
11   readonly baseTime: number;
12   /**
13    * Excludes a row before either accessor runs and produces a derived gap.
14    * @param datum - Original source observation.
15    * @param index - Original source index.
16    * @param data - Complete original source array.
17    * @returns Whether this observation is eligible for the base and derived points.
18    */
19   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
20 }
21 
22 /** One source-ordered cumulative simple-return observation. See {@link percentageReturns}. */
23 export interface PercentageReturnPoint<D> {
24   /** Caller-owned original observation. */
25   readonly datum: D;
26   /** Original index, including rows that become gaps. */
27   readonly sourceIndex: number;
28   /** Evaluated finite timestamp, or null for a missing, invalid, or excluded row. */
29   readonly time: number | null;
30   /** Percentage change from the common base, or null for a gap. */
31   readonly returnPercent: number | null;
32 }
33 
34 /** One source-ordered base-100 indexed observation. See {@link indexTo100}. */
35 export interface IndexedValuePoint<D> {
36   /** Caller-owned original observation. */
37   readonly datum: D;
38   /** Original index, including rows that become gaps. */
39   readonly sourceIndex: number;
40   /** Evaluated finite timestamp, or null for a missing, invalid, or excluded row. */
41   readonly time: number | null;
42   /** Value indexed to 100 at the common base, or null for a gap. */
43   readonly indexValue: number | null;
44 }
45 
46 const DATE_LIMIT = 8_640_000_000_000_000;
47 
48 function validBaseTime(value: number): void {
49   if (!Number.isSafeInteger(value) || Math.abs(value) > DATE_LIMIT)
50     throw new RangeError(
51       "comparison baseTime must be a safe integer UTC millisecond within Date range.",
52     );
53 }
54 
55 function percentageOf(value: number, base: number): number {
56   // Multiplying first preserves tiny finite indexed values; dividing first preserves
57   // large finite ratios whose multiplication would otherwise overflow unnecessarily.
58   return Math.abs(value) <= Number.MAX_VALUE / 100
59     ? (value * 100) / base
60     : (value / base) * 100;
61 }
62 
63 function returnPercent(value: number, base: number): number {
64   if (value === base) return 0;
65   const difference = value - base;
66   return Number.isFinite(difference)
67     ? (difference / base) * 100
68     : percentageOf(value, base) - 100;
69 }
70 
71 interface EvaluatedComparisonRow {
72   readonly eligible: boolean;
73   readonly time: number | null;
74   readonly value: number | null;
75 }
76 
77 function comparisonBase(
78   rows: readonly EvaluatedComparisonRow[],
79   baseTime: number,
80 ): number | undefined {
81   let matches = 0;
82   let baseValue: number | undefined;
83   for (const row of rows) {
84     if (!row.eligible || row.time !== baseTime || row.value === null) continue;
85     matches++;
86     baseValue = row.value;
87   }
88   if (matches === 0) return undefined;
89   if (matches > 1)
90     throw new RangeError(
91       "comparison baseTime matches multiple eligible complete observations and is ambiguous.",
92     );
93   if (baseValue! <= 0)
94     throw new RangeError("comparison base observation must be positive.");
95   return baseValue;
96 }
97 
98 function comparisonPoints<D, P>(
99   data: readonly D[],
100   options: ComparisonBaseOptions<D>,
101   derived: (value: number, base: number) => number,
102   point: (datum: D, sourceIndex: number, time: number | null, value: number | null) => P,
103 ): readonly P[] | undefined {
104   const rows = pointData(data, "comparison");
105   validBaseTime(options.baseTime);
106   const time = pointAccessor(options.time);
107   const value = pointAccessor(options.value);
108   const evaluated = rows.map((datum, index): EvaluatedComparisonRow => {
109     const eligible = options.defined?.(datum, index, rows) ?? true;
110     if (!eligible) return Object.freeze({ eligible, time: null, value: null });
111     const timestamp = time(datum, index, rows);
112     if (!finiteCoordinate(timestamp))
113       return Object.freeze({ eligible, time: null, value: null });
114     const observed = value(datum, index, rows);
115     return Object.freeze({
116       eligible,
117       time: timestamp,
118       value: finiteCoordinate(observed) ? observed : null,
119     });
120   });
121   const base = comparisonBase(evaluated, options.baseTime);
122   if (base === undefined) return undefined;
123   return Object.freeze(rows.map((datum, sourceIndex) => {
124     const row = evaluated[sourceIndex]!;
125     if (!row.eligible || row.time === null)
126       return Object.freeze(point(datum, sourceIndex, null, null));
127     if (row.value === null)
128       return Object.freeze(point(datum, sourceIndex, row.time, null));
129     const result = derived(row.value, base);
130     if (!Number.isFinite(result))
131       throw new RangeError(
132         `comparison derived value at source index ${sourceIndex} must be finite.`,
133       );
134     return Object.freeze(point(datum, sourceIndex, row.time, result));
135   }));
136 }
137 
138 /**
139  * Derives source-ordered cumulative simple percentage returns from one exact positive base.
140  *
141  * Each defined finite observation becomes `(value / baseValue - 1) * 100`, expressed in
142  * percentage points. This is a fixed-base return series, not a period-over-period return.
143  * Missing/nonfinite values become explicit gaps; a missing exact base returns undefined.
144  * @param data - Caller-owned source observations in presentation order.
145  * @param options - Time/value accessors, exact common base, and optional eligibility predicate.
146  * @returns Frozen points, or undefined when no eligible complete exact base exists.
147  * @see {@link ComparisonBaseOptions}
148  */
149 export function percentageReturns<D>(
150   data: readonly D[],
151   options: ComparisonBaseOptions<D>,
152 ): readonly PercentageReturnPoint<D>[] | undefined {
153   return comparisonPoints(
154     data,
155     options,
156     returnPercent,
157     (datum, sourceIndex, time, returnPercent) => ({
158       datum,
159       sourceIndex,
160       time,
161       returnPercent,
162     }),
163   );
164 }
165 
166 /**
167  * Derives source-ordered values indexed to 100 at one exact positive base observation.
168  *
169  * Missing/nonfinite values become explicit gaps; a missing exact base returns undefined.
170  * @param data - Caller-owned source observations in presentation order.
171  * @param options - Time/value accessors, exact common base, and optional eligibility predicate.
172  * @returns Frozen points, or undefined when no eligible complete exact base exists.
173  * @see {@link ComparisonBaseOptions}
174  */
175 export function indexTo100<D>(
176   data: readonly D[],
177   options: ComparisonBaseOptions<D>,
178 ): readonly IndexedValuePoint<D>[] | undefined {
179   return comparisonPoints(
180     data,
181     options,
182     (value, base) => value === base ? 100 : percentageOf(value, base),
183     (datum, sourceIndex, time, indexValue) => ({
184       datum,
185       sourceIndex,
186       time,
187       indexValue,
188     }),
189   );
190 }
191 

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