@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 +17 -2
- package/package.json +30 -4
- package/src/index.ts +1 -0
- package/src/live-performance-kpis.ts +501 -0
package/README.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @digithings-ai/perf-math
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|