Skip to content

packages/core/src/features/viz/lib/financial-aggregation.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 /** One half-open time interval used to group complete OHLCV records. See {@link AggregateOhlcvOptions}. */
5 export interface OhlcvBucket {
6   /** Inclusive finite start coordinate. */
7   readonly start: number;
8   /** Exclusive finite end coordinate after the start. */
9   readonly end: number;
10 }
11 
12 /** Original source record that contributed to an aggregated OHLCV bar. See {@link AggregatedOhlcvPoint}. */
13 export interface OhlcvSource<D> {
14   /** Caller-owned complete contributing record. */
15   readonly datum: D;
16   /** Original source index. */
17   readonly sourceIndex: number;
18 }
19 
20 /** One frozen OHLCV bar aggregated without sorting caller-owned records. See {@link aggregateOhlcv}. */
21 export interface AggregatedOhlcvPoint<D> extends OhlcvBucket {
22   /** Open from the first contributing record. */
23   readonly open: number;
24   /** Maximum high from contributing records. */
25   readonly high: number;
26   /** Minimum low from contributing records. */
27   readonly low: number;
28   /** Close from the last contributing record. */
29   readonly close: number;
30   /** Finite sum of non-negative contributing volumes. */
31   readonly volume: number;
32   /** Frozen references to contributing caller-owned records. */
33   readonly sources: readonly OhlcvSource<D>[];
34 }
35 
36 /** Inputs for chronological OHLCV aggregation. See {@link aggregateOhlcv}. */
37 export interface AggregateOhlcvOptions<D> {
38   /** Numeric property or callback that supplies each record's time coordinate. */
39   readonly time: LineAccessor<D>;
40   /** Numeric property or callback that supplies open. */
41   readonly open: LineAccessor<D>;
42   /** Numeric property or callback that supplies high. */
43   readonly high: LineAccessor<D>;
44   /** Numeric property or callback that supplies low. */
45   readonly low: LineAccessor<D>;
46   /** Numeric property or callback that supplies close. */
47   readonly close: LineAccessor<D>;
48   /** Numeric property or callback that supplies non-negative volume. */
49   readonly volume: LineAccessor<D>;
50   /**
51    * Returns the half-open aggregate bucket for one eligible finite timestamp, or null to skip it.
52    * @param time - Eligible finite source timestamp.
53    * @returns A containing half-open bucket, or null when the record is not aggregated.
54    * @see {@link OhlcvBucket}
55    */
56   readonly bucket: (time: number) => OhlcvBucket | null;
57   /**
58    * Excludes a record before its OHLCV and bucket accessors run.
59    * @param datum - Original source observation.
60    * @param index - Zero-based source index.
61    * @param data - Complete caller-supplied source array.
62    * @returns Whether this observation may contribute.
63    */
64   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
65 }
66 
67 /** A valid session identifier supplied to {@link sessionVwap}. */
68 export type SessionIdentifier = string | number;
69 
70 /**
71  * A property or callback that supplies the session containing a source record.
72  * @param datum - Source observation under consideration.
73  * @param index - Zero-based source index.
74  * @param data - Complete caller-supplied source array.
75  * @returns The record's string or finite numeric session identifier.
76  * @see {@link SessionVwapOptions}
77  */
78 export type SessionAccessor<D> =
79   | { [K in keyof D]-?: D[K] extends SessionIdentifier ? K : never }[keyof D]
80   | ((datum: D, index: number, data: readonly D[]) => SessionIdentifier);
81 
82 /** Inputs for source-aligned session VWAP. See {@link sessionVwap}. */
83 export interface SessionVwapOptions<D> {
84   /** Numeric property or callback that supplies each record's price. */
85   readonly price: LineAccessor<D>;
86   /** Numeric property or callback that supplies non-negative volume. */
87   readonly volume: LineAccessor<D>;
88   /** Property or callback that supplies the current string or finite numeric session identifier. */
89   readonly session: SessionAccessor<D>;
90   /**
91    * Excludes a row before price and volume accessors run; its session still resets state.
92    * @param datum - Original source observation.
93    * @param index - Zero-based source index.
94    * @param data - Complete caller-supplied source array.
95    * @returns Whether this observation participates in VWAP calculation.
96    */
97   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
98 }
99 
100 /** One frozen source-aligned session VWAP point. See {@link sessionVwap}. */
101 export interface SessionVwapPoint<D> {
102   /** Caller-owned original record. */
103   readonly datum: D;
104   /** Original source index, including excluded and incomplete records. */
105   readonly sourceIndex: number;
106   /** Evaluated session, including for an excluded source record. */
107   readonly session: SessionIdentifier;
108   /** Finite volume accumulated for this session before or including this record. */
109   readonly cumulativeVolume: number;
110   /** Session VWAP after this valid record, null for incomplete or excluded records. */
111   readonly vwap: number | null;
112 }
113 
114 function validBucket(value: OhlcvBucket | null, time: number): OhlcvBucket | null {
115   if (value === null) return null;
116   if (value === undefined || typeof value !== "object" || Array.isArray(value))
117     throw new TypeError("aggregateOhlcv bucket must return an object or null.");
118   const { start, end } = value;
119   if (!finiteCoordinate(start) || !finiteCoordinate(end) || start >= end)
120     throw new RangeError("aggregateOhlcv bucket bounds must be finite and increasing.");
121   if (time < start || time >= end)
122     throw new RangeError("aggregateOhlcv bucket must contain its source time.");
123   return value;
124 }
125 
126 function validateCandle(
127   open: number, high: number, low: number, close: number, volume: number,
128 ): void {
129   if (volume < 0) throw new RangeError("aggregateOhlcv volume must be non-negative.");
130   if (low > high || open < low || open > high || close < low || close > high)
131     throw new RangeError("aggregateOhlcv high and low must contain open and close.");
132 }
133 
134 function sessionAccessor<D>(input: SessionAccessor<D>): (datum: D, index: number, data: readonly D[]) => SessionIdentifier {
135   return typeof input === "function"
136     ? input
137     : (datum) => datum[input] as SessionIdentifier;
138 }
139 
140 function validSession(value: unknown): value is SessionIdentifier {
141   if (typeof value === "string") return true;
142   if (typeof value === "number" && Number.isFinite(value)) return true;
143   throw new TypeError("sessionVwap session must be a string or finite number.");
144 }
145 
146 function weightedAverage(previous: number, previousVolume: number, price: number, volume: number): number {
147   const total = previousVolume + volume;
148   if (!Number.isFinite(total)) throw new RangeError("sessionVwap cumulative volume must be finite.");
149   const scale = Math.max(Math.abs(previous), Math.abs(price));
150   if (scale === 0) return 0;
151   const result = ((previous / scale) * (previousVolume / total) + (price / scale) * (volume / total)) * scale;
152   if (!Number.isFinite(result)) throw new RangeError("sessionVwap derived value must be finite.");
153   return result;
154 }
155 
156 /**
157  * Aggregates source-ordered complete OHLCV records into caller-defined chronological buckets.
158  *
159  * It never sorts or mutates source records. Missing, excluded, and null-bucket records are skipped;
160  * finite malformed candles, negative volume, noncontiguous bucket bounds, and time disorder reject.
161  * @param data - Caller-owned source observations in chronological order.
162  * @param options - OHLCV accessors, bucket definition, and optional participation callback.
163  * @returns Frozen anchored aggregate bars with frozen original source references.
164  * @see {@link AggregateOhlcvOptions}
165  * @see {@link AggregatedOhlcvPoint}
166  */
167 export function aggregateOhlcv<D>(data: readonly D[], options: AggregateOhlcvOptions<D>): readonly AggregatedOhlcvPoint<D>[] {
168   const rows = pointData(data, "aggregateOhlcv");
169   const time = pointAccessor(options.time), open = pointAccessor(options.open), high = pointAccessor(options.high);
170   const low = pointAccessor(options.low), close = pointAccessor(options.close), volume = pointAccessor(options.volume);
171   const aggregates: AggregatedOhlcvPoint<D>[] = [];
172   let previousTime: number | undefined;
173   let current: { start: number; end: number; open: number; high: number; low: number; close: number; volume: number; sources: OhlcvSource<D>[] } | undefined;
174   for (let index = 0; index < rows.length; index++) {
175     const datum = rows[index]!;
176     if (!(options.defined?.(datum, index, rows) ?? true)) continue;
177     const timestamp = time(datum, index, rows);
178     if (!finiteCoordinate(timestamp)) continue;
179     if (previousTime !== undefined && timestamp < previousTime)
180       throw new RangeError("aggregateOhlcv data must be chronological.");
181     previousTime = timestamp;
182     const interval = validBucket(options.bucket(timestamp), timestamp);
183     if (interval === null) continue;
184     const o = open(datum, index, rows), h = high(datum, index, rows), l = low(datum, index, rows);
185     const c = close(datum, index, rows), v = volume(datum, index, rows);
186     if (!finiteCoordinate(o) || !finiteCoordinate(h) || !finiteCoordinate(l)
187       || !finiteCoordinate(c) || !finiteCoordinate(v)) continue;
188     validateCandle(o, h, l, c, v);
189     if (current === undefined || current.start !== interval.start || current.end !== interval.end) {
190       if (current !== undefined) {
191         if (interval.start < current.end)
192           throw new RangeError("aggregateOhlcv buckets must be non-overlapping and source ordered.");
193         aggregates.push(Object.freeze({ ...current, sources: Object.freeze(current.sources) }));
194       }
195       current = { start: interval.start, end: interval.end, open: o, high: h, low: l, close: c, volume: v, sources: [Object.freeze({ datum, sourceIndex: index })] };
196       continue;
197     }
198     const nextVolume = current.volume + v;
199     if (!Number.isFinite(nextVolume)) throw new RangeError("aggregateOhlcv volume must remain finite.");
200     current.high = Math.max(current.high, h);
201     current.low = Math.min(current.low, l);
202     current.close = c;
203     current.volume = nextVolume;
204     current.sources.push(Object.freeze({ datum, sourceIndex: index }));
205   }
206   if (current !== undefined)
207     aggregates.push(Object.freeze({ ...current, sources: Object.freeze(current.sources) }));
208   return Object.freeze(aggregates);
209 }
210 
211 /**
212  * Derives source-aligned volume-weighted prices that reset whenever the session identifier changes.
213  *
214  * Missing prices or volumes and excluded rows produce null without changing the current accumulated
215  * state. Zero-volume complete records preserve the preceding VWAP; negative volumes reject.
216  * @param data - Caller-owned source observations in presentation order.
217  * @param options - Price, volume, session accessors, and optional participation callback.
218  * @returns Frozen source-aligned points with current cumulative volume and VWAP or null.
219  * @see {@link SessionVwapOptions}
220  * @see {@link SessionVwapPoint}
221  */
222 export function sessionVwap<D>(data: readonly D[], options: SessionVwapOptions<D>): readonly SessionVwapPoint<D>[] {
223   const rows = pointData(data, "sessionVwap");
224   const price = pointAccessor(options.price), volume = pointAccessor(options.volume), session = sessionAccessor(options.session);
225   const results: SessionVwapPoint<D>[] = [];
226   let activeSession: SessionIdentifier | undefined;
227   let cumulativeVolume = 0;
228   let average: number | undefined;
229   for (let index = 0; index < rows.length; index++) {
230     const datum = rows[index]!;
231     const currentSession = session(datum, index, rows);
232     validSession(currentSession);
233     if (activeSession !== currentSession) {
234       activeSession = currentSession;
235       cumulativeVolume = 0;
236       average = undefined;
237     }
238     if (!(options.defined?.(datum, index, rows) ?? true)) {
239       results.push(Object.freeze({ datum, sourceIndex: index, session: currentSession, cumulativeVolume, vwap: null }));
240       continue;
241     }
242     const currentPrice = price(datum, index, rows), currentVolume = volume(datum, index, rows);
243     if (!finiteCoordinate(currentPrice) || !finiteCoordinate(currentVolume)) {
244       results.push(Object.freeze({ datum, sourceIndex: index, session: currentSession, cumulativeVolume, vwap: null }));
245       continue;
246     }
247     if (currentVolume < 0) throw new RangeError("sessionVwap volume must be non-negative.");
248     if (currentVolume > 0) {
249       average = average === undefined ? currentPrice : weightedAverage(average, cumulativeVolume, currentPrice, currentVolume);
250       cumulativeVolume += currentVolume;
251       if (!Number.isFinite(cumulativeVolume)) throw new RangeError("sessionVwap cumulative volume must be finite.");
252     }
253     results.push(Object.freeze({ datum, sourceIndex: index, session: currentSession, cumulativeVolume, vwap: average ?? null }));
254   }
255   return Object.freeze(results);
256 }
257 

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