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