@digithings-ai/perf-math 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # @digithings-ai/perf-math
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-free portfolio performance math: live KPIs, base-100 continuity index,
4
+ since-inception, Jensen alpha and information ratio.
5
+
6
+ **Dependency-free on purpose.** No React, no React DOM, no DOM, no I/O, no
7
+ `@digithings-ai/*` edges. That is what lets `@digithings-ai/contract` and the R5 Worker
8
+ bundle use this math without pulling the `@digithings-ai/ui` component layer — which
9
+ would drag React into a Worker that cannot run it.
10
+
11
+ `@digithings-ai/ui` re-exports every symbol here from its tearsheet entry point, so
12
+ existing `@digithings-ai/ui` imports keep working.
13
+
14
+ ```ts
15
+ import { computeLivePerformanceKpis } from "@digithings-ai/perf-math";
16
+ ```
17
+
18
+ Tests: `npm test --workspace @digithings-ai/perf-math`
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@digithings-ai/perf-math",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Framework-free portfolio performance math: live KPIs, base-100 continuity, beta/alpha/IR. No React, no DOM, no I/O.",
5
+ "license": "MIT",
6
+ "homepage": "https://digithings.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/digithings-ai/digithings.git",
10
+ "directory": "packages/perf-math"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./live-performance-kpis": "./src/live-performance-kpis.ts"
16
+ },
17
+ "files": [
18
+ "src/index.ts",
19
+ "src/live-performance-kpis.ts"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "test": "vitest run",
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^6.0.2",
30
+ "vitest": "^4.1.0"
31
+ }
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./live-performance-kpis";
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Live portfolio performance KPIs — shared by digiquant.io landing, dashboard Brief,
3
+ * and (via series helpers) the Performance tearsheet.
4
+ *
5
+ * Computes percentage returns (day, since inception, vs benchmark) from:
6
+ * - current holdings (weight_pct)
7
+ * - latest marks (live tick or last close)
8
+ * - accounting NAV history for prior-day / inception anchors
9
+ *
10
+ * NAV is a paper base-100 index used only as an internal valuation anchor — never
11
+ * the primary display. Pure — no React, no I/O.
12
+ *
13
+ * Insight metrics (documented definitions):
14
+ * - excess / relative return: Rp − Rb over the aligned window (percentage points)
15
+ * - relative gain: alias of excess return (same window)
16
+ * - alpha: single-factor Jensen alpha = Rp − β·Rb, with β from overlapping daily
17
+ * returns (OLS). Null when fewer than {@link MIN_OVERLAP_DAYS} pairs — we do
18
+ * not invent CAPM alpha from endpoints alone.
19
+ * - information ratio: mean(daily excess) / sampleStd(daily excess) · √252.
20
+ * Null when overlap is too short or tracking error is ~0.
21
+ */
22
+
23
+ export interface LiveKpiPosition {
24
+ ticker: string;
25
+ weightPct: number;
26
+ /** Daily-close mark from the published book (`current_price`). */
27
+ markPrice: number | null;
28
+ /** Best available price: live tick when fresh, else mark. */
29
+ effectivePrice: number | null;
30
+ /** True when `effectivePrice` came from a non-stale live tick. */
31
+ isLive: boolean;
32
+ /** Book mark date (YYYY-MM-DD) from `metrics_as_of`. */
33
+ metricsAsOf: string | null;
34
+ /** Live quote calendar date (YYYY-MM-DD) when `isLive`. */
35
+ livePriceDate: string | null;
36
+ }
37
+
38
+ export interface LiveKpiNavPoint {
39
+ date: string;
40
+ nav: number;
41
+ /**
42
+ * Accounting day return for the row (percent points). Preferred step for the
43
+ * continuity index; absent on rows that predate the accounting contract.
44
+ */
45
+ day_return_pct?: number | null;
46
+ /** Row source (`legacy_nav_history` / `finalized_accounting`). */
47
+ source?: string | null;
48
+ /** True on the first row after a legacy↔finalized flip. */
49
+ series_seam?: boolean | null;
50
+ }
51
+
52
+ export interface LiveKpiBenchmarkPoint {
53
+ date: string;
54
+ price: number;
55
+ }
56
+
57
+ export interface LivePerformanceKpisInput {
58
+ positions: readonly LiveKpiPosition[];
59
+ navHistory: readonly LiveKpiNavPoint[];
60
+ benchmarkHistory?: readonly LiveKpiBenchmarkPoint[];
61
+ benchmarkTicker?: string | null;
62
+ }
63
+
64
+ export interface LivePerformanceKpis {
65
+ /**
66
+ * Live-marked base-100 index level (internal). Prefer returning % metrics
67
+ * to UI — expose only when a footnote needs the raw index.
68
+ */
69
+ liveNav: number | null;
70
+ /** Dimensionless move vs snapshot marks (percent points). */
71
+ liveVsMarkPct: number;
72
+ /** Price date used for KPI labels (YYYY-MM-DD). */
73
+ priceAsOfDate: string | null;
74
+ dayReturnPct: number | null;
75
+ sinceInceptionPct: number | null;
76
+ sinceInceptionStartDate: string | null;
77
+ /** Portfolio total return over the aligned benchmark window (pp). */
78
+ portfolioReturnPct: number | null;
79
+ /** Benchmark total return over the same window (pp). */
80
+ benchmarkReturnPct: number | null;
81
+ /** Rp − Rb (percentage points). Primary relative metric. */
82
+ excessReturnPct: number | null;
83
+ /** Alias of {@link excessReturnPct} — same aligned window. */
84
+ relativeGainPct: number | null;
85
+ /**
86
+ * Jensen alpha = Rp − β·Rb (percentage points on the total-return window),
87
+ * with β from overlapping daily returns. Null when overlap is insufficient.
88
+ */
89
+ alphaPct: number | null;
90
+ /**
91
+ * Annualized information ratio from overlapping daily active returns.
92
+ * Null when overlap is insufficient or tracking error ≈ 0.
93
+ */
94
+ informationRatio: number | null;
95
+ benchmarkTicker: string | null;
96
+ /** Latest accounting NAV row date (book persistence). */
97
+ bookNavDate: string | null;
98
+ }
99
+
100
+ /** Minimum overlapping daily pairs for beta / IR (honest sample, not endpoints). */
101
+ export const MIN_OVERLAP_DAYS = 20;
102
+
103
+ const TRADING_DAYS = 252;
104
+ const BASE100_EPS = 0.01;
105
+
106
+ const asDate = (iso: string | null | undefined): string | null => {
107
+ if (!iso) return null;
108
+ const d = iso.slice(0, 10);
109
+ return /^\d{4}-\d{2}-\d{2}$/.test(d) ? d : null;
110
+ };
111
+
112
+ /** Weighted live move vs published marks — same formula as digiquant-web computeLiveTotal. */
113
+ export function computeLiveVsMarkPct(positions: readonly LiveKpiPosition[]): number {
114
+ let move = 0;
115
+ for (const p of positions) {
116
+ const mark = p.markPrice;
117
+ const price = p.effectivePrice;
118
+ if (mark == null || mark <= 0 || price == null || price <= 0) continue;
119
+ if (p.isLive) {
120
+ move += (p.weightPct / 100) * (price / mark - 1);
121
+ }
122
+ }
123
+ return move * 100;
124
+ }
125
+
126
+ /** Dominant price-as-of date across the book for KPI footnotes. */
127
+ export function derivePriceAsOfDate(positions: readonly LiveKpiPosition[]): string | null {
128
+ let liveMax: string | null = null;
129
+ let markMax: string | null = null;
130
+ for (const p of positions) {
131
+ if (p.isLive && p.livePriceDate) {
132
+ if (!liveMax || p.livePriceDate > liveMax) liveMax = p.livePriceDate;
133
+ }
134
+ const markDate = asDate(p.metricsAsOf);
135
+ if (markDate && (!markMax || markDate > markMax)) markMax = markDate;
136
+ }
137
+ return liveMax ?? markMax;
138
+ }
139
+
140
+ /** Mirror of `CONTINUITY_MAX_STEP_PCT` in `apps/dashboard-api/src/ssot.ts`. */
141
+ const CONTINUITY_MAX_STEP_PCT = 25;
142
+
143
+ /** Mirror of `isNavSeriesSeam` in `apps/dashboard-api/src/ssot.ts`. */
144
+ function isNavSeriesSeam(
145
+ row: LiveKpiNavPoint,
146
+ prevSource: string | null | undefined,
147
+ ): boolean {
148
+ if (row.series_seam === true) return true;
149
+ if (prevSource == null) return false;
150
+ const source = row.source ?? null;
151
+ // `prevSource` is non-null here (early return above); only `source` needs the check.
152
+ return source != null && source !== prevSource;
153
+ }
154
+
155
+ /**
156
+ * One chain step (percent points). Prefers the row's own day return, clamps a
157
+ * move that cannot be performance, and returns 0 across a source seam — so a
158
+ * rebasing/flow level jump never enters the index.
159
+ */
160
+ function continuityStepPct(row: LiveKpiNavPoint, previous: LiveKpiNavPoint | undefined): number {
161
+ const daily = row.day_return_pct;
162
+ if (daily != null && Number.isFinite(daily)) {
163
+ return Math.abs(daily) <= CONTINUITY_MAX_STEP_PCT ? daily : 0;
164
+ }
165
+ if (!isNavSeriesSeam(row, previous?.source ?? null) && previous != null && previous.nav > 0) {
166
+ const level = (row.nav / previous.nav - 1) * 100;
167
+ if (Number.isFinite(level) && Math.abs(level) <= CONTINUITY_MAX_STEP_PCT) return level;
168
+ }
169
+ return 0;
170
+ }
171
+
172
+ /**
173
+ * Base-100 continuity index over the accounting rows — the SAME chain `/brief`
174
+ * plots. Mirror of `buildContinuityNavSeries` (apps/dashboard-api/src/ssot.ts),
175
+ * without the 2dp rounding that module applies for display.
176
+ *
177
+ * Accounting NAV levels carry flows and rebasing that never appear in
178
+ * `day_return_pct`; dividing NAV by 100 counts those level jumps as returns.
179
+ */
180
+ export function continuityIndexSeries(rows: readonly LiveKpiNavPoint[]): LiveKpiNavPoint[] {
181
+ const sorted = [...rows]
182
+ .filter((r) => Number.isFinite(r.nav) && r.nav > 0)
183
+ .sort((a, b) => a.date.localeCompare(b.date));
184
+ if (sorted.length === 0) return [];
185
+ const out: LiveKpiNavPoint[] = [{ date: sorted[0].date, nav: 100 }];
186
+ let index = 100;
187
+ for (let i = 1; i < sorted.length; i += 1) {
188
+ index *= 1 + continuityStepPct(sorted[i], sorted[i - 1]) / 100;
189
+ out.push({ date: sorted[i].date, nav: index });
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Since-inception % from a base-100 (or arbitrary) NAV level.
196
+ * When the series seeds at ~100, this equals `lastNav - 100`.
197
+ */
198
+ export function sinceInceptionPctFromNav(firstNav: number, lastNav: number): number | null {
199
+ if (!(firstNav > 0) || !Number.isFinite(lastNav)) return null;
200
+ return (lastNav / firstNav - 1) * 100;
201
+ }
202
+
203
+ /**
204
+ * Invariant: when the book seeds at base 100, since-inception sign must match
205
+ * whether the live index is above or below 100. Returns false on disagreement.
206
+ */
207
+ export function inceptionSignAgreesWithBase100(
208
+ firstNav: number,
209
+ lastNav: number,
210
+ sincePct: number
211
+ ): boolean {
212
+ if (Math.abs(firstNav - 100) >= BASE100_EPS) return true;
213
+ if (Math.abs(lastNav - 100) < BASE100_EPS) return Math.abs(sincePct) < BASE100_EPS;
214
+ if (lastNav > 100) return sincePct > 0;
215
+ if (lastNav < 100) return sincePct < 0;
216
+ return true;
217
+ }
218
+
219
+ function pickBenchmarkPoints(
220
+ history: readonly LiveKpiBenchmarkPoint[],
221
+ startDate: string,
222
+ endDate: string
223
+ ): { start: LiveKpiBenchmarkPoint; end: LiveKpiBenchmarkPoint } | null {
224
+ const sorted = [...history].sort((a, b) => a.date.localeCompare(b.date));
225
+ const start = sorted.find((p) => p.date >= startDate);
226
+ const end = [...sorted].reverse().find((p) => p.date <= endDate);
227
+ if (!start || !end || start.date > end.date) return null;
228
+ if (start.price <= 0 || end.price <= 0) return null;
229
+ return { start, end };
230
+ }
231
+
232
+ function mean(xs: number[]): number {
233
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
234
+ }
235
+
236
+ function sampleStd(xs: number[]): number {
237
+ if (xs.length < 2) return 0;
238
+ const m = mean(xs);
239
+ const v = xs.reduce((a, x) => a + (x - m) ** 2, 0) / (xs.length - 1);
240
+ return Math.sqrt(v);
241
+ }
242
+
243
+ /**
244
+ * Align NAV and benchmark to common dates (as-of forward fill on benchmark),
245
+ * then emit overlapping daily simple returns for both series.
246
+ */
247
+ export function overlappingDailyReturns(
248
+ navHistory: readonly LiveKpiNavPoint[],
249
+ benchmarkHistory: readonly LiveKpiBenchmarkPoint[]
250
+ ): { port: number[]; bench: number[] } {
251
+ const nav = [...navHistory].sort((a, b) => a.date.localeCompare(b.date));
252
+ const bench = [...benchmarkHistory].sort((a, b) => a.date.localeCompare(b.date));
253
+ if (nav.length < 2 || bench.length < 2) return { port: [], bench: [] };
254
+
255
+ const port: number[] = [];
256
+ const benchRets: number[] = [];
257
+ let bi = -1;
258
+ let priorNav: number | null = null;
259
+ let priorBench: number | null = null;
260
+
261
+ for (const row of nav) {
262
+ while (bi + 1 < bench.length && bench[bi + 1].date <= row.date) bi += 1;
263
+ const bPrice = bi >= 0 ? bench[bi].price : null;
264
+ if (priorNav != null && priorNav > 0 && priorBench != null && priorBench > 0 && bPrice != null && bPrice > 0) {
265
+ port.push(row.nav / priorNav - 1);
266
+ benchRets.push(bPrice / priorBench - 1);
267
+ }
268
+ priorNav = row.nav;
269
+ if (bPrice != null && bPrice > 0) priorBench = bPrice;
270
+ }
271
+ return { port, bench: benchRets };
272
+ }
273
+
274
+ /**
275
+ * OLS beta of portfolio daily returns on benchmark daily returns.
276
+ * Null when sample is shorter than {@link MIN_OVERLAP_DAYS}.
277
+ */
278
+ export function olsBeta(portDaily: readonly number[], benchDaily: readonly number[]): number | null {
279
+ const n = Math.min(portDaily.length, benchDaily.length);
280
+ if (n < MIN_OVERLAP_DAYS) return null;
281
+ const p = portDaily.slice(0, n);
282
+ const b = benchDaily.slice(0, n);
283
+ const mp = mean(p);
284
+ const mb = mean(b);
285
+ let cov = 0;
286
+ let varB = 0;
287
+ for (let i = 0; i < n; i++) {
288
+ const dp = p[i] - mp;
289
+ const db = b[i] - mb;
290
+ cov += dp * db;
291
+ varB += db * db;
292
+ }
293
+ if (varB < 1e-18) return null;
294
+ return cov / varB;
295
+ }
296
+
297
+ /**
298
+ * Annualized information ratio from overlapping daily active returns.
299
+ * IR = mean(Rp−Rb) / std(Rp−Rb) · √252.
300
+ */
301
+ export function informationRatioFromDaily(
302
+ portDaily: readonly number[],
303
+ benchDaily: readonly number[]
304
+ ): number | null {
305
+ const n = Math.min(portDaily.length, benchDaily.length);
306
+ if (n < MIN_OVERLAP_DAYS) return null;
307
+ const active: number[] = [];
308
+ for (let i = 0; i < n; i++) active.push(portDaily[i] - benchDaily[i]);
309
+ const te = sampleStd(active);
310
+ if (te < 1e-12) return null;
311
+ return (mean(active) / te) * Math.sqrt(TRADING_DAYS);
312
+ }
313
+
314
+ /**
315
+ * Build the NAV series used for overlapping daily returns when a live tip
316
+ * may sit on a later calendar day than the last accounting row.
317
+ *
318
+ * Keep every accounting observation and append the live tip when `endDate`
319
+ * is strictly after the book — dropping the last accounting row can drop a
320
+ * valid overlap pair below {@link MIN_OVERLAP_DAYS}.
321
+ */
322
+ export function navHistoryForLiveOverlap(
323
+ sortedNav: readonly LiveKpiNavPoint[],
324
+ endDate: string,
325
+ liveNav: number
326
+ ): LiveKpiNavPoint[] {
327
+ const last = sortedNav.at(-1);
328
+ if (!last) return [{ date: endDate, nav: liveNav }];
329
+ if (endDate === last.date) {
330
+ return [...sortedNav.slice(0, -1), { date: endDate, nav: liveNav }];
331
+ }
332
+ if (endDate > last.date) {
333
+ return [...sortedNav, { date: endDate, nav: liveNav }];
334
+ }
335
+ return [...sortedNav];
336
+ }
337
+
338
+ /**
339
+ * Day-return anchor: when price marks are on a calendar day after the latest
340
+ * accounting NAV row, the day baseline is that latest close. When marks share
341
+ * the book date (post-EOD), baseline is the prior NAV row so the printed day
342
+ * return still reflects the last completed session.
343
+ */
344
+ export function dayReturnAnchorNav(
345
+ sortedNav: readonly LiveKpiNavPoint[],
346
+ priceAsOfDate: string | null
347
+ ): number | null {
348
+ if (sortedNav.length === 0) return null;
349
+ const latest = sortedNav[sortedNav.length - 1];
350
+ const prior = sortedNav.length >= 2 ? sortedNav[sortedNav.length - 2] : null;
351
+ if (priceAsOfDate && latest.date && priceAsOfDate > latest.date) {
352
+ return latest.nav > 0 ? latest.nav : null;
353
+ }
354
+ if (prior && prior.nav > 0) return prior.nav;
355
+ return latest.nav > 0 ? latest.nav : null;
356
+ }
357
+
358
+ /**
359
+ * Compute live performance KPIs for landing + Brief scoreboard.
360
+ *
361
+ * Since-inception always uses `(liveNav / firstNav − 1) · 100` so the sign
362
+ * cannot disagree with a base-100 index level.
363
+ */
364
+ export function computeLivePerformanceKpis(input: LivePerformanceKpisInput): LivePerformanceKpis {
365
+ const { positions, navHistory, benchmarkHistory, benchmarkTicker } = input;
366
+ const sortedNav = [...navHistory].sort((a, b) => a.date.localeCompare(b.date));
367
+ const latestNavRow = sortedNav.length > 0 ? sortedNav[sortedNav.length - 1] : null;
368
+ const latestNav = latestNavRow?.nav ?? null;
369
+ const liveVsMarkPct = computeLiveVsMarkPct(positions);
370
+ const liveNav = latestNav == null ? null : latestNav * (1 + liveVsMarkPct / 100);
371
+ const priceAsOfDate = derivePriceAsOfDate(positions);
372
+ const bookNavDate = latestNavRow?.date ?? null;
373
+
374
+ const anchor = dayReturnAnchorNav(sortedNav, priceAsOfDate);
375
+ const dayReturnPct =
376
+ liveNav != null && anchor != null && anchor > 0 ? (liveNav / anchor - 1) * 100 : null;
377
+
378
+ const firstNavRow = sortedNav.length > 0 ? sortedNav[0] : null;
379
+ // Since-inception is the SAME base-100 continuity index `/brief` plots — never
380
+ // `nav / 100`. Accounting NAV levels carry flows and rebasing that never appear
381
+ // in `day_return_pct`, so a raw-level ratio reports non-performance level jumps
382
+ // as returns. The live move is applied to the INDEX tip, not the NAV tip.
383
+ const continuity = continuityIndexSeries(sortedNav);
384
+ const indexTip = continuity.length > 0 ? continuity[continuity.length - 1].nav : null;
385
+ const liveIndex = indexTip == null ? null : indexTip * (1 + liveVsMarkPct / 100);
386
+ const sinceInceptionPct =
387
+ liveIndex != null && continuity.length >= 2
388
+ ? sinceInceptionPctFromNav(continuity[0].nav, liveIndex)
389
+ : null;
390
+
391
+ let portfolioReturnPct: number | null = sinceInceptionPct;
392
+ let benchmarkReturnPct: number | null = null;
393
+ let excessReturnPct: number | null = null;
394
+ let alphaPct: number | null = null;
395
+ let informationRatio: number | null = null;
396
+ const benchTickerOut: string | null = benchmarkTicker ?? null;
397
+
398
+ if (
399
+ liveNav != null &&
400
+ sinceInceptionPct != null &&
401
+ firstNavRow != null &&
402
+ benchmarkHistory?.length &&
403
+ benchTickerOut
404
+ ) {
405
+ // Prefer live mark date only when it is *after* the book row; a stale
406
+ // metrics_as_of earlier than bookNavDate must not shrink the window while
407
+ // portfolio return still uses the later liveNav.
408
+ const endDate =
409
+ priceAsOfDate && bookNavDate && priceAsOfDate > bookNavDate
410
+ ? priceAsOfDate
411
+ : (bookNavDate ?? priceAsOfDate ?? latestNavRow?.date ?? null);
412
+ const startDate = firstNavRow.date;
413
+ if (endDate) {
414
+ const aligned = pickBenchmarkPoints(benchmarkHistory, startDate, endDate);
415
+ if (aligned) {
416
+ // Portfolio leg is the index-based since-inception — the same number
417
+ // `/brief` reports, so the two can never disagree. The benchmark leg
418
+ // stays level-based: a price index carries no flows.
419
+ portfolioReturnPct = sinceInceptionPct;
420
+ benchmarkReturnPct = (aligned.end.price / aligned.start.price - 1) * 100;
421
+ excessReturnPct = portfolioReturnPct - benchmarkReturnPct;
422
+
423
+ const { port, bench } = overlappingDailyReturns(
424
+ navHistoryForLiveOverlap(sortedNav, endDate, liveNav),
425
+ benchmarkHistory
426
+ );
427
+ const beta = olsBeta(port, bench);
428
+ // `portfolioReturnPct`/`benchmarkReturnPct` are numbers here (assigned from
429
+ // null-checked values above); only `beta` needs the null check.
430
+ if (beta != null) {
431
+ // Jensen alpha on the total-return window using daily-estimated β.
432
+ alphaPct = portfolioReturnPct - beta * benchmarkReturnPct;
433
+ }
434
+ informationRatio = informationRatioFromDaily(port, bench);
435
+ }
436
+ }
437
+ }
438
+
439
+ return {
440
+ liveNav,
441
+ liveVsMarkPct,
442
+ priceAsOfDate,
443
+ dayReturnPct,
444
+ sinceInceptionPct,
445
+ sinceInceptionStartDate: firstNavRow?.date ?? null,
446
+ portfolioReturnPct,
447
+ benchmarkReturnPct,
448
+ excessReturnPct,
449
+ relativeGainPct: excessReturnPct,
450
+ alphaPct,
451
+ informationRatio,
452
+ benchmarkTicker: benchTickerOut,
453
+ bookNavDate,
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Relative / risk-adjusted metrics from aligned cumulative return series
459
+ * (Performance tearsheet). Series are rebased to 0% at the first point.
460
+ */
461
+ export function relativeMetricsFromReturnSeries(
462
+ portfolioReturnPct: number | null,
463
+ benchmarkReturnPct: number | null,
464
+ portfolioCumulativePct: readonly number[],
465
+ benchmarkCumulativePct: readonly number[]
466
+ ): {
467
+ excessReturnPct: number | null;
468
+ relativeGainPct: number | null;
469
+ alphaPct: number | null;
470
+ informationRatio: number | null;
471
+ } {
472
+ const excessReturnPct =
473
+ portfolioReturnPct != null && benchmarkReturnPct != null
474
+ ? portfolioReturnPct - benchmarkReturnPct
475
+ : null;
476
+
477
+ const toDaily = (cum: readonly number[]): number[] => {
478
+ const out: number[] = [];
479
+ for (let i = 1; i < cum.length; i++) {
480
+ const prev = 1 + cum[i - 1] / 100;
481
+ const cur = 1 + cum[i] / 100;
482
+ if (prev > 0) out.push(cur / prev - 1);
483
+ }
484
+ return out;
485
+ };
486
+
487
+ const portDaily = toDaily(portfolioCumulativePct);
488
+ const benchDaily = toDaily(benchmarkCumulativePct);
489
+ const beta = olsBeta(portDaily, benchDaily);
490
+ const alphaPct =
491
+ beta != null && portfolioReturnPct != null && benchmarkReturnPct != null
492
+ ? portfolioReturnPct - beta * benchmarkReturnPct
493
+ : null;
494
+
495
+ return {
496
+ excessReturnPct,
497
+ relativeGainPct: excessReturnPct,
498
+ alphaPct,
499
+ informationRatio: informationRatioFromDaily(portDaily, benchDaily),
500
+ };
501
+ }