diff --git a/docs/PERFORMANCE_ATTRIBUTION.md b/docs/PERFORMANCE_ATTRIBUTION.md index f164b2e..e9ad5a7 100644 --- a/docs/PERFORMANCE_ATTRIBUTION.md +++ b/docs/PERFORMANCE_ATTRIBUTION.md @@ -26,16 +26,23 @@ and is the correct input for the benchmark side, matching ### The benchmark, v1 -No real market index exists yet. v1 defines "the market" as the -**equal-weighted average of available `ProtocolRate` APY history** — every -protocol with a rate quote on a given day counts as one equally-weighted -sector of the benchmark that day (or a configured subset via -`ATTRIBUTION_BENCHMARK_PROTOCOLS`). The pure module never reads -`ProtocolRate` itself: it accepts `RawProtocolRatePoint[]` (the same type -`src/agent/backtest.ts` defines for the backtest engine), so a real index feed -can be dropped in later by supplying a differently-sourced series in the same -shape. `benchmarkVersion` on every persisted row names which definition/subset -produced it, so a later config change never silently reinterprets an old row. +No real market index exists yet. v1 defines "the market" as the average of +available `ProtocolRate` APY history — every protocol with a rate quote on a +given day counts as a member of the benchmark that day (or a configured subset +via `ATTRIBUTION_BENCHMARK_PROTOCOLS`). + +**One definition, one place.** The market is defined exactly once, in +`src/analytics/benchmark.ts` (`buildMarketFactorSeries`), defaulting to +**equal-weighted** with a pluggable **TVL-weighted** alternative that falls +back to equal when no TVL data is available. `attribution.ts` imports this +canonical series rather than re-deriving the market (Flaunch #352); a golden +test pins attribution's output so a benchmark change can never silently alter +an attribution number. A real index feed can be dropped in later by supplying +a differently-sourced series in the same shape. + +`benchmarkVersion` on every persisted attribution row names which +definition/subset produced it, so a later config change never silently +reinterprets an old row. ### Sectors, v1 @@ -174,7 +181,64 @@ means. --- -## 5. Out of scope (deliberately) +## 5. Rolling beta & market-factor exposure (#352) + +Attribution answers *why* a portfolio out/under-performed. Factor exposure +answers a different question: **how much of a portfolio's yield movement is +explained by the DeFi-yield "market factor" versus idiosyncratic protocol +selection**, and — because it is computed on a rolling window — **how that +exposure is changing over time** rather than as a single point estimate. + +The market factor is the canonical series from `src/analytics/benchmark.ts`. +A **yield co-movement beta** is computed by OLS of the portfolio's daily value +return on the market's daily return. A beta of ~1 means "your yield moves with +the tracked-protocol market"; ~0 means "independent of it". + +### The pure core + +`src/analytics/factorExposure.ts` — zero I/O, fixture-tested: + +- `rollingBeta(portfolioReturns, marketReturns, windowSize, step)` returns one + `{ windowEndMs, beta, alpha, rSquared, sampleCount }` per window; windows + under `MIN_FACTOR_SAMPLES` (14) or with effectively-zero market variance + return **null** statistics — never NaN, never a fabricated 0. +- `factorDecomposition(...)` runs one OLS over the full window → + `{ beta, alpha(annualized), rSquared, idiosyncraticVolShare }`, where + `idiosyncraticVolShare = 1 − R²` is "how much of your yield variance is your + protocol selection". + +### DB glue + alignment + +`src/analytics/factorExposureService.ts` reads the user's `YieldSnapshot` +value buckets (`principal + yield`, never `apy`) and the benchmark universe's +`ProtocolRate` history, builds both daily series on the **same UTC-day grid**, +and keeps **only days present on both sides** — mismatched days are dropped, +never zero-filled; `sampleCount` is the intersection (`MIN_FACTOR_SAMPLES` of +these are required before beta means anything). + +### API + +`GET /api/v1/analytics/factor-exposure?window=90d&rollingWindow=30d` +(both optional; `window ∈ {30d,60d,90d}`, `rollingWindow ∈ {7d,14d,30d}`, +`weighting ∈ {equal,tvl}`) — authenticated, owner-scoped via +`req.auth.userId`. Returns `{ rolling, summary, benchmark, insufficientHistory, +sampleCount, caveats, inputHash, computedAt }`. + +- Retention is bounded at 90 days; `rollingWindow` must be **shorter than** + `window` (400 otherwise), because YieldSnapshots are hard-deleted past 90 + days. +- A `rollingWindow` that leaves fewer than 2 windows returns the **summary + only**, with a caveat. +- Every response ships `FACTOR_CAVEAT`: *"The 'market' is the equal-weighted + average of tracked protocol APY series, not a traded index. Beta here + measures yield co-movement, not price beta."* +- Deterministic: protocols sorted, `asOf` explicit, and an `inputHash` + (sha256 over the sorted portfolio-value + benchmark-rate snapshot) returned + so the report can be reproduced. + +--- + +## 6. Out of scope (deliberately) 1. A live external benchmark index feed — the module accepts an exogenous `RawProtocolRatePoint[]` series; sourcing a real index is deferred. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1b0e239..6c4f873 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1084,6 +1084,155 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /analytics/factor-exposure: + get: + operationId: getFactorExposure + summary: Rolling beta and market-factor exposure report + description: | + Measures how much of the authenticated user's yield movement is + explained by the DeFi-yield "market factor" versus idiosyncratic + protocol selection, on a rolling window so exposure can be seen + changing over time (#352). + + The market is the canonical equal/TVL-weighted average of tracked + `ProtocolRate` APY series (`src/analytics/benchmark.ts`). Each window + regresses portfolio daily return on market daily return (OLS): a beta + of ~1 means "your yield moves with the tracked-protocol market", ~0 + means "independent of it". + + This is YIELD co-movement, NOT price beta, and the market is not a + traded index — the fixed `caveats[0]` states this on every response. + Under-sampled (< 14 aligned days) or zero-market-variance windows + report `null` beta/R², never 0 or NaN. Series are intersected on a + shared daily grid (never zero-filled); mismatched days are dropped and + `sampleCount` reflects the intersection. + + Retention is bounded at 90 days and `rollingWindow` must be shorter + than `window`; a rolling window that leaves fewer than 2 windows + returns the summary only, with a caveat. `inputHash` (sha256 of the + sorted input snapshot) lets the report be reproduced deterministically. + tags: [Analytics] + security: + - bearerAuth: [] + parameters: + - name: window + in: query + required: false + schema: + type: string + enum: [30d, 60d, 90d] + default: 90d + - name: rollingWindow + in: query + required: false + schema: + type: string + enum: [7d, 14d, 30d] + default: 30d + - name: weighting + in: query + required: false + schema: + type: string + enum: [equal, tvl] + default: equal + responses: + '200': + description: Rolling beta and market-factor exposure report. + content: + application/json: + schema: + type: object + properties: + userId: + type: string + format: uuid + window: + type: string + rollingWindow: + type: string + weighting: + type: string + enum: [equal, tvl] + actualWindowDays: + type: integer + insufficientHistory: + type: boolean + sampleCount: + type: integer + rolling: + type: array + items: + type: object + properties: + windowEndMs: + type: integer + sampleCount: + type: integer + beta: + type: number + nullable: true + alpha: + type: number + nullable: true + alphaAnnualized: + type: number + nullable: true + rSquared: + type: number + nullable: true + idiosyncraticVolShare: + type: number + nullable: true + summary: + type: object + nullable: true + properties: + sampleCount: + type: integer + beta: + type: number + nullable: true + alpha: + type: number + nullable: true + alphaAnnualized: + type: number + nullable: true + rSquared: + type: number + nullable: true + idiosyncraticVolShare: + type: number + nullable: true + benchmark: + type: object + properties: + weighting: + type: string + enum: [equal, tvl] + universeSize: + type: integer + tvlFallback: + type: boolean + caveats: + type: array + items: + type: string + inputHash: + type: string + computedAt: + type: string + format: date-time + '400': + description: Invalid query parameters (e.g. rollingWindow >= window). + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + /analytics/yield-breakdown: get: operationId: getYieldBreakdown diff --git a/src/analytics/attribution.ts b/src/analytics/attribution.ts index c781b37..3ce5358 100644 --- a/src/analytics/attribution.ts +++ b/src/analytics/attribution.ts @@ -90,13 +90,10 @@ * never a divide-by-zero. */ -import { RawProtocolRatePoint, buildDailyRateSeries } from '../agent/backtest' +import { RawProtocolRatePoint } from '../agent/backtest' +import { buildMarketFactorSeries, BenchmarkRatePoint } from './benchmark' const MS_PER_DAY = 24 * 60 * 60 * 1000 -const MS_PER_YEAR = 365.25 * MS_PER_DAY - -/** One day's worth of a single period, as a fraction of a year (see backtest.ts's identical convention). */ -const YEAR_FRACTION_PER_DAY = MS_PER_DAY / MS_PER_YEAR /** * How far a linked reconciliation may drift from zero before being flagged @@ -456,10 +453,12 @@ export interface AttributionInput { /** * Raw, possibly gappy protocol rate observations forming the benchmark * universe — already filtered to the configured protocol subset, or every - * protocol if unrestricted. Reused verbatim by `buildDailyRateSeries`, so - * the benchmark inherits its documented hold-last-known forward-fill. + * protocol if unrestricted. Reused verbatim by `buildMarketFactorSeries` + * (src/analytics/benchmark.ts), so the benchmark inherits its documented + * hold-last-known forward-fill. This module never defines the market itself; + * it imports the canonical series from benchmark.ts (Flaunch/#352). */ - benchmarkRates: RawProtocolRatePoint[] + benchmarkRates: BenchmarkRatePoint[] /** 30 or 90 — see docs/STRATEGY_MARKETPLACE.md's retention-honesty rule; this module does not enforce the enum itself. */ windowDays: number /** Reference "now", injected for deterministic tests. */ @@ -484,11 +483,12 @@ export function computeAttribution(input: AttributionInput): AttributionResult { startDate, endDate ) - const { series: benchmarkSeries } = buildDailyRateSeries( - input.benchmarkRates, + const { series: benchmarkSeries } = buildMarketFactorSeries({ + rates: input.benchmarkRates, startDate, - endDate - ) + endDate, + weighting: 'equal', + }) if (portfolioSeries.length < 2 || benchmarkSeries.length < 2) { return emptyResult(input.windowDays, input.benchmarkVersion) @@ -510,21 +510,21 @@ export function computeAttribution(input: AttributionInput): AttributionResult { for (let t = 1; t < portfolioSeries.length; t++) { const prevValues = portfolioSeries[t - 1].values const currValues = portfolioSeries[t].values - const benchmarkDay = benchmarkSeries[t - 1] // rate quoted at the START of the period + // Market factor quoted at the START of the period, from the canonical + // benchmark series (buildMarketFactorSeries, equal-weighted). + const benchmarkDay = benchmarkSeries[t - 1] const totalPortfolioStart = Object.values(prevValues).reduce( (s, v) => s + v, 0 ) - const benchmarkSectorCount = benchmarkDay.protocols.length // No benchmark data at all this day: nothing to compare against. Skip the // whole period rather than fabricating a 0% market return. - if (benchmarkSectorCount === 0) continue + if (benchmarkDay.sectors.length === 0) continue - const benchmarkWeight = 1 / benchmarkSectorCount const sectorNames = new Set([ ...Object.keys(prevValues), - ...benchmarkDay.protocols.map((p) => p.name), + ...benchmarkDay.sectors.map((s) => s.name), ]) const sectorStates: SectorState[] = [] @@ -536,25 +536,23 @@ export function computeAttribution(input: AttributionInput): AttributionResult { const portfolioReturn = startValue > 0 ? (endValue - startValue) / startValue : null - const benchmarkProtocol = benchmarkDay.protocols.find( - (p) => p.name === sector + const benchmarkSector = benchmarkDay.sectors.find( + (s) => s.name === sector ) - const hasBenchmark = benchmarkProtocol !== undefined - const benchmarkReturn = hasBenchmark - ? (benchmarkProtocol.apy / 100) * YEAR_FRACTION_PER_DAY - : null - + const hasBenchmark = benchmarkSector !== undefined + // The benchmark's weight AND its daily return fraction come straight + // from the shared market factor definition — one source of truth. sectorStates.push({ sector, portfolioWeight, portfolioReturn, - benchmarkWeight: hasBenchmark ? benchmarkWeight : 0, - benchmarkReturn, + benchmarkWeight: hasBenchmark ? benchmarkSector.weight : 0, + benchmarkReturn: hasBenchmark ? benchmarkSector.returnFraction : null, }) const w = weightSum.get(sector) ?? { p: 0, b: 0 } w.p += portfolioWeight - w.b += hasBenchmark ? benchmarkWeight : 0 + w.b += hasBenchmark ? benchmarkSector.weight : 0 weightSum.set(sector, w) if (portfolioWeight > 0 && portfolioReturn !== null) { @@ -566,12 +564,12 @@ export function computeAttribution(input: AttributionInput): AttributionResult { c.everHeld = true compoundedPortfolio.set(sector, c) } - if (hasBenchmark && benchmarkReturn !== null) { + if (hasBenchmark && benchmarkSector.returnFraction !== null) { const c = compoundedBenchmark.get(sector) ?? { product: 1, everSeen: false, } - c.product *= 1 + benchmarkReturn + c.product *= 1 + benchmarkSector.returnFraction c.everSeen = true compoundedBenchmark.set(sector, c) } diff --git a/src/analytics/benchmark.ts b/src/analytics/benchmark.ts new file mode 100644 index 0000000..36faf48 --- /dev/null +++ b/src/analytics/benchmark.ts @@ -0,0 +1,201 @@ +/** + * Market-factor benchmark series (#352) — pure computation. + * + * This is the SINGLE place the DeFi-yield "market" is defined across + * src/analytics/. v1 defines "the market" as a daily average of available + * `ProtocolRate` APY history — no real traded index exists yet. Every analyzer + * that needs a market series (attribution today, factor exposure next) imports + * `buildMarketFactorSeries` from here instead of re-deriving the definition. + * + * ─── UNITS ─────────────────────────────────────────────────────────────────── + * + * `ProtocolRate.supplyApy` is an ANNUAL rate quote. A benchmark is a series of + * RETURNS, so each day's sector contribution is the daily-accrued fraction of + * that annual APY: + * + * returnFraction = (supplyApy / 100) * YEAR_FRACTION_PER_DAY + * + * using the identical year convention as src/agent/backtest.ts + * (365.25 days/year). The aggregate market return for a day is the + * weight-weighted sum of those fractions. + * + * ─── WEIGHTING ──────────────────────────────────────────────────────────────── + * + * - `equal` (default): every protocol that has a rate quote on a given day is + * one equally-weighted member of that day's benchmark — the exact v1 + * attribution definition, promoted to be shared. + * - `tvl`: weights by the protocol's TVL on that day. TVL is carried forward + * day-over-day exactly like APY (see below). When no TVL is available for a + * day (all protocols missing TVL, or total TVL <= 0) the day falls back to + * equal weighting; the overall series is flagged `tvlFallback: true` if any + * populated day could not use TVL. + * + * ─── GAP-HANDLING ──────────────────────────────────────────────────────────── + * + * Reuses the exact forward-fill policy from `buildDailyRateSeries` + * (src/agent/backtest.ts): a protocol with no observation for a day holds its + * last known APY and TVL; a protocol with no observations up to and including + * a day is absent that day. This guarantees alignment with every other series + * built by `buildDailyRateSeries` so a portfolio series and the market series + * can never disagree about which days exist. + * + * Zero I/O, deterministic, unit tested — src/jobs/attribution.ts and + * src/analytics/factorExposure.ts read the DB and call in here. + */ + +import { RawProtocolRatePoint } from '../agent/backtest' +import { buildDailyRateSeries } from '../agent/backtest' + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const MS_PER_YEAR = 365.25 * MS_PER_DAY +const YEAR_FRACTION_PER_DAY = MS_PER_DAY / MS_PER_YEAR + +/** A benchmark rate observation — `RawProtocolRatePoint` plus optional TVL for weighting. */ +export interface BenchmarkRatePoint extends RawProtocolRatePoint { + /** Protocol TVL for the sampled day, used by `tvl` weighting. Missing => day falls back to equal. */ + tvl?: number | null +} + +export type BenchmarkWeighting = 'equal' | 'tvl' + +/** One protocol's contribution to one day's market factor. */ +export interface BenchmarkSectorPoint { + name: string + /** Market weight of this protocol this day (sums to 1 over the day's sectors). */ + weight: number + /** Daily-accrued return fraction for this sector ((apy/100) * YEAR_FRACTION_PER_DAY). */ + returnFraction: number +} + +/** One day of the market factor series. */ +export interface MarketFactorDay { + date: Date + /** Aggregate market daily return = sum(weight * returnFraction); null when no sectors. */ + marketReturn: number | null + /** Per-protocol weights/returns this day — the benchmark's sector decomposition. */ + sectors: BenchmarkSectorPoint[] +} + +export interface MarketFactorSeries { + series: MarketFactorDay[] + /** The weighting actually applied (`'equal'` if a `tvl` request fell back). */ + weighting: BenchmarkWeighting + /** True when `tvl` was requested but at least one populated day fell back to equal. */ + tvlFallback: boolean +} + +export interface BuildMarketFactorInput { + /** Raw ProtocolRate observations (optionally carrying tvl), any order, possibly gappy. */ + rates: BenchmarkRatePoint[] + startDate: Date + endDate: Date + /** 'equal' (default) or 'tvl'. */ + weighting?: BenchmarkWeighting +} + +/** + * Build the daily market-factor RETURN series on the aligned grid shared with + * `buildDailyRateSeries`. Equal weighting is default; `tvl` falls back to + * equal per-day when TVL is unavailable, flagged on the result. + */ +export function buildMarketFactorSeries( + input: BuildMarketFactorInput +): MarketFactorSeries { + const weighting = input.weighting ?? 'equal' + + // Reuse backtest's forward-fill to get the per-day available protocol set + // and APY — guarantees the factor series is index-aligned with any series + // attribution/estimation already build from the same raw rates. + const apyPoints: RawProtocolRatePoint[] = input.rates.map((r) => ({ + protocolName: r.protocolName, + assetSymbol: r.assetSymbol, + apy: r.apy, + date: r.date, + })) + const { series: dailyRateSeries } = buildDailyRateSeries( + apyPoints, + input.startDate, + input.endDate + ) + + // Parallel forward-fill for TVL (only needed for tvl weighting). + const tvlSorted = new Map() + for (const r of input.rates) { + if (r.tvl == null || !Number.isFinite(r.tvl)) continue + const list = tvlSorted.get(r.protocolName) ?? [] + list.push({ tvl: r.tvl, date: r.date }) + tvlSorted.set(r.protocolName, list) + } + for (const [name, list] of tvlSorted) { + list.sort((a, b) => a.date.getTime() - b.date.getTime()) + } + const tvlPointer = new Map() + const tvlHeld = new Map() + const hasAnyTvl = tvlSorted.size > 0 + + const out: MarketFactorDay[] = [] + let anyTvlDay = false + let anyPopulatedEqualFallback = false + + for (const day of dailyRateSeries) { + // Advance TVL forward-fill to this day. + for (const [name, list] of tvlSorted) { + let idx = tvlPointer.get(name) ?? 0 + while ( + idx < list.length && + list[idx].date.getTime() <= day.date.getTime() + ) { + tvlHeld.set(name, list[idx].tvl) + idx++ + } + tvlPointer.set(name, idx) + } + + if (day.protocols.length === 0) { + out.push({ date: day.date, marketReturn: null, sectors: [] }) + continue + } + + const wantTvl = weighting === 'tvl' && hasAnyTvl + let totalTvl = 0 + const tvlForDay = new Map() + if (wantTvl) { + for (const p of day.protocols) { + const tvl = tvlHeld.get(p.name) + if (tvl != null && tvl > 0) { + tvlForDay.set(p.name, tvl) + totalTvl += tvl + } + } + } + + const useTvl = wantTvl && totalTvl > 0 && tvlForDay.size > 0 + if (useTvl) anyTvlDay = true + else if (weighting === 'tvl') anyPopulatedEqualFallback = true + + const sectors: BenchmarkSectorPoint[] = [] + let marketReturn = 0 + for (const p of day.protocols) { + const rf = (p.apy / 100) * YEAR_FRACTION_PER_DAY + let w: number + if (useTvl) { + w = (tvlForDay.get(p.name) ?? 0) / totalTvl + } else { + w = 1 / day.protocols.length + } + sectors.push({ name: p.name, weight: w, returnFraction: rf }) + marketReturn += w * rf + } + + out.push({ date: day.date, marketReturn, sectors }) + } + + const appliedWeighting: BenchmarkWeighting = + weighting === 'equal' || !anyTvlDay ? 'equal' : 'tvl' + + return { + series: out, + weighting: appliedWeighting, + tvlFallback: weighting === 'tvl' && anyPopulatedEqualFallback, + } +} diff --git a/src/analytics/factorExposure.ts b/src/analytics/factorExposure.ts new file mode 100644 index 0000000..2d2ae96 --- /dev/null +++ b/src/analytics/factorExposure.ts @@ -0,0 +1,246 @@ +/** + * Rolling beta & market-factor exposure (#352) — pure computation. + * + * Measures how much of a portfolio's yield movement is explained by the + * DeFi-yield "market factor" (the canonical series from `benchmark.ts`) versus + * idiosyncratic protocol selection, on a rolling window so exposure can be seen + * CHANGING over time — not just as a single point estimate. + * + * Zero I/O, deterministic, fixture-tested. The DB glue that reads + * YieldSnapshot / ProtocolRate and builds the aligned return series lives in + * `factorExposureService.ts`; nothing here touches the database or a clock. + * + * ─── WHAT "BETA" MEANS HERE ─────────────────────────────────────────────────── + * + * This is a yield co-movement beta, NOT an asset-price beta. The market factor + * is the equal/TVL-weighted average of tracked protocol APY returns, and the + * portfolio series is the same value-derived daily return. A beta of ~1 means + * "your yield moves with the tracked-protocol market"; of ~0 means "your yield + * is independent of it". It says nothing about principal loss, depeg, or + * smart-contract failure. This is stated plainly in the fixed caveat on the + * API route. + * + * ─── OLS MODEL ──────────────────────────────────────────────────────────────── + * + * Each window regresses portfolio daily return (y) on market daily return (x): + * + * β = Cov(x, y) / Var(x) + * α = mean(y) − β · mean(x) + * R² = 1 − SS_res / SS_tot + * + * Returns are daily fractions (e.g. 0.0003 for +0.03%/day). `alphaAnnualized` + * is the daily intercept scaled by 365.25 (simple, non-compounding — matches + * the one-decimal APY convention used across src/analytics). + * + * ─── NULL-ON-DEGENERATE (inherited from metrics.ts) ─────────────────────────── + * + * A window under the sample minimum or with zero market variance returns + * `null` for every statistic — never 0 (0 would falsely signal "no exposure") + * and never NaN/Infinity. A beta of exactly 0 is only ever a genuine output. + */ + +/** Minimum aligned samples for beta to mean anything — mirrors MIN_ALIGNED_OBSERVATIONS. */ +export const MIN_FACTOR_SAMPLES = 14 + +/** + * Below this SXX magnitude the market is treated as having NO VARIANCE. + * + * Daily returns are bounded fractions (typically ~1e-4), so a genuinely moving + * market yields SXX well above 1e-8, while an all-but-constant market yields + * only floating-point jitter (~1e-36). An absolute threshold cleanly separates + * the two without coupling to the specific series being regressed. + */ +const MIN_MARKET_SXX = 1e-12 + +const MS_PER_YEAR = 365.25 * 24 * 60 * 60 * 1000 +const DAYS_PER_YEAR = 365.25 + +export interface RollingBetaPoint { + /** UTC ms of the window's last sample. */ + windowEndMs: number + /** Number of aligned (intersected) samples in the window. */ + sampleCount: number + /** Null when sampleCount < MIN_FACTOR_SAMPLES or market variance is ~0. */ + beta: number | null + /** Daily alpha (OLS intercept), null on degenerate. */ + alpha: number | null + /** Daily alpha annualized (simple, x365.25), null on degenerate. */ + alphaAnnualized: number | null + /** R² in [0,1], null on degenerate. */ + rSquared: number | null + /** 1 − R² — the share of yield variance not explained by the market factor. */ + idiosyncraticVolShare: number | null +} + +export interface FactorDecomposition { + /** Full-window sample count. */ + sampleCount: number + beta: number | null + /** Daily alpha (OLS intercept). */ + alpha: number | null + /** Daily alpha annualized (simple, x365.25). */ + alphaAnnualized: number | null + rSquared: number | null + /** 1 − R² — "how much of your yield variance is your protocol selection". */ + idiosyncraticVolShare: number | null +} + +interface OLSResult { + beta: number + alpha: number + rSquared: number +} + +/** + * Ordinary least squares of y on x with a constant. Returns null when there is + * no market variance to regress against (denominator ~0) or < 2 points. + * Never throws, never produces NaN/Infinity. + */ +function ols(xs: number[], ys: number[]): OLSResult | null { + const n = xs.length + if (n < 2) return null + let mx = 0 + let my = 0 + for (let i = 0; i < n; i++) { + mx += xs[i] + my += ys[i] + } + mx /= n + my /= n + + let sxx = 0 + let sxy = 0 + let syy = 0 + for (let i = 0; i < n; i++) { + const dx = xs[i] - mx + const dy = ys[i] - my + sxx += dx * dx + sxy += dx * dy + syy += dy * dy + } + + // Zero (or effectively zero) market variance: regression undefined. The + // MIN_MARKET_SXX bound rejects floating-point jitter from a constant series. + if (!(sxx > MIN_MARKET_SXX)) return null + + const beta = sxy / sxx + const alpha = my - beta * mx + + // Clamp R² into [0,1] against floating-point overshoot. SS_tot can be ~0 + // when the portfolio never moves; treat a constant portfolio as fully + // explained relative to itself is misleading, so null when there is no + // portfolio variance to explain either. + let rSquared: number + if (syy <= 0) { + rSquared = 1 // constant portfolio has zero variance to explain + } else { + const ssRes = syy - beta * sxy + rSquared = Math.max(0, Math.min(1, 1 - ssRes / syy)) + } + + return { beta, alpha, rSquared } +} + +function annualizeAlpha(dailyAlpha: number): number { + return dailyAlpha * DAYS_PER_YEAR +} + +function toPoint( + xs: number[], + ys: number[], + windowEndMs: number +): RollingBetaPoint { + const sampleCount = xs.length + const fit = sampleCount >= MIN_FACTOR_SAMPLES ? ols(xs, ys) : null + return { + windowEndMs, + sampleCount, + beta: fit?.beta ?? null, + alpha: fit?.alpha ?? null, + alphaAnnualized: fit ? annualizeAlpha(fit.alpha) : null, + rSquared: fit?.rSquared ?? null, + idiosyncraticVolShare: + fit && fit.rSquared !== null ? 1 - fit.rSquared : null, + } +} + +export interface RollingBetaInput { + /** Aligned portfolio daily return series (fractions), same length as marketReturns. */ + portfolioReturns: number[] + /** Aligned market daily return series (fractions). */ + marketReturns: number[] + /** Rolling window size in samples (days). Must be <= array length for any windows. */ + windowSize: number + /** Advance between windows in samples. Defaults to windowSize (non-overlapping). */ + step?: number + /** + * Aligned UTC-day-end ms for each sample (same length as the returns). When + * omitted, windowEndMs falls back to the 1-indexed sample position (index+1). + */ + timestampsMs?: number[] +} + +/** + * Rolling OLS beta of portfolio returns on market returns, advanced by `step` + * samples per window. Always returns at least the summary when the arrays are + * non-empty; under-sampled or zero-variance windows carry all-null statistics. + */ +export function rollingBeta(input: RollingBetaInput): RollingBetaPoint[] { + const step = input.step ?? input.windowSize + const n = input.portfolioReturns.length + const out: RollingBetaPoint[] = [] + + if (step <= 0 || input.windowSize <= 0) return out + if (n === 0 || n !== input.marketReturns.length) return out + + const ts = input.timestampsMs + + for (let start = 0; start + input.windowSize <= n; start += step) { + const end = start + input.windowSize - 1 + const xs = input.marketReturns.slice(start, start + input.windowSize) + const ys = input.portfolioReturns.slice(start, start + input.windowSize) + const windowEndMs = ts ? ts[end] : end + 1 + out.push(toPoint(xs, ys, windowEndMs)) + } + + return out +} + +export interface FactorDecompositionInput { + /** Aligned portfolio daily return series. */ + portfolioReturns: number[] + /** Aligned market daily return series. */ + marketReturns: number[] +} + +/** + * Full-window factor decomposition (single OLS over the whole aligned series). + * Degenerate inputs return null statistics with the sample count preserved so + * callers can explain _why_. + */ +export function factorDecomposition( + input: FactorDecompositionInput +): FactorDecomposition { + // Intersect the two series by truncating to the shorter length — the pure + // core receives index-aligned series already; mismatched lengths here are a + // defensive truncation, never a zero-fill. + const aligned = Math.min( + input.portfolioReturns.length, + input.marketReturns.length + ) + + const xs = input.marketReturns.slice(0, aligned) + const ys = input.portfolioReturns.slice(0, aligned) + + const fit = aligned >= MIN_FACTOR_SAMPLES ? ols(xs, ys) : null + + return { + sampleCount: aligned, + beta: fit?.beta ?? null, + alpha: fit?.alpha ?? null, + alphaAnnualized: fit ? annualizeAlpha(fit.alpha) : null, + rSquared: fit?.rSquared ?? null, + idiosyncraticVolShare: + fit && fit.rSquared !== null ? 1 - fit.rSquared : null, + } +} diff --git a/src/analytics/factorExposureService.ts b/src/analytics/factorExposureService.ts new file mode 100644 index 0000000..fa35933 --- /dev/null +++ b/src/analytics/factorExposureService.ts @@ -0,0 +1,329 @@ +/** + * Factor-exposure DB glue (#352). + * + * Reads the DB (the user's YieldSnapshot value buckets for the portfolio + * series, ProtocolRate history on the configured benchmark universe for the + * market series) and hands the ALIGNED daily-return pairs to the pure cores in + * src/analytics/benchmark.ts + src/analytics/factorExposure.ts. No statistics + * live here — mirroring src/analytics/correlationService.ts. + * + * ─── ALIGNMENT (shared daily grid, never zero-fill) ────────────────────────── + * + * The portfolio value series and the market factor series are both built on + * the same UTC-day grid. Only days on which BOTH a portfolio daily return and + * a market daily return exist are kept as aligned observation pairs; any day + * missing on either side is dropped, never zero-filled. `sampleCount` is the + * size of that intersection. + * + * ─── RETENTION & VALIDATION ────────────────────────────────────────────────── + * + * Yield snapshots are hard-deleted past 90 days (src/agent/snapshotter.ts), so + * the window is capped at 90d and the API refuses `rollingWindow >= window`. + * A rollingWindow that leaves fewer than 2 windows is reported as summary-only + * with a caveat rather than a fabricated trend. + */ + +import crypto from 'crypto' +import db from '../db' +import { config } from '../config/env' +import { + buildMarketFactorSeries, + BenchmarkRatePoint, + MarketFactorDay, +} from './benchmark' +import { + rollingBeta, + factorDecomposition, + MIN_FACTOR_SAMPLES, + RollingBetaPoint, +} from './factorExposure' + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const RETENTION_DAYS = 90 + +/** + * The fixed caveat that ships with every factor-exposure response. "Beta" here + * is yield co-movement, never price beta, and the market is a synthetic + * tracked-protocol average, not a traded index. + */ +export const FACTOR_CAVEAT = + "The 'market' is the equal-weighted average of tracked protocol APY series, not a traded index. Beta here measures yield co-movement, not price beta." + +export type FactorExposureWeighting = 'equal' | 'tvl' + +export interface FactorExposureOptions { + /** Trailing window in days (capped at 90). Default 90. */ + windowDays?: number + /** Rolling window in samples (days). Must be < windowDays. Default 30. */ + rollingWindowDays?: number + /** 'equal' (default) or 'tvl'. */ + weighting?: FactorExposureWeighting + /** Reference "now", injected for deterministic tests. */ + now?: Date +} + +export interface FactorExposureResult { + userId: string + windowDays: number + actualWindowDays: number + rollingWindowDays: number + /** True when there aren't enough aligned samples for beta to mean anything. */ + insufficientHistory: boolean + /** Number of aligned (intersected) daily observations. */ + sampleCount: number + rolling: RollingBetaPoint[] + summary: { + sampleCount: number + beta: number | null + alpha: number | null + alphaAnnualized: number | null + rSquared: number | null + idiosyncraticVolShare: number | null + } | null + benchmark: { + weighting: FactorExposureWeighting + universeSize: number + tvlFallback: boolean + } + /** Always includes FACTOR_CAVEAT; may add reason-specific caveats. */ + caveats: string[] + inputHash: string + computedAt: string +} + +/** Whole-portfolio value per UTC day for a user (end-of-day mark from YieldSnapshot value buckets). */ +async function loadPortfolioValueSeries( + userId: string, + fromDate: Date, + now: Date +): Promise> { + const snapshots = await db.yieldSnapshot.findMany({ + where: { + position: { userId }, + snapshotAt: { gte: fromDate, lte: now }, + }, + select: { + snapshotAt: true, + principalAmount: true, + yieldAmount: true, + }, + orderBy: { snapshotAt: 'asc' }, + }) + + const dayValue = new Map() + const dayTs = new Map() + for (const s of snapshots) { + const day = Math.floor(s.snapshotAt.getTime() / MS_PER_DAY) * MS_PER_DAY + const ts = s.snapshotAt.getTime() + const prevTs = dayTs.get(day) + // Latest snapshot of the day wins (end-of-day mark), order-independent. + if (prevTs !== undefined && prevTs >= ts) continue + dayTs.set(day, ts) + dayValue.set(day, Number(s.principalAmount) + Number(s.yieldAmount)) + } + return dayValue +} + +/** The benchmark universe's raw rate observations (optionally with tvl), filtered to the configured subset. */ +async function loadBenchmarkRates( + fromDate: Date +): Promise { + const subset = config.attribution.benchmarkProtocols + const rates = await db.protocolRate.findMany({ + where: { + fetchedAt: { gte: fromDate }, + ...(subset.length > 0 ? { protocolName: { in: subset } } : {}), + }, + select: { + protocolName: true, + assetSymbol: true, + supplyApy: true, + tvl: true, + fetchedAt: true, + }, + }) + + return rates.map((r) => ({ + protocolName: r.protocolName, + assetSymbol: r.assetSymbol, + apy: Number(r.supplyApy), + tvl: r.tvl != null ? Number(r.tvl) : null, + date: r.fetchedAt, + })) +} + +/** + * Intersect the portfolio value series with the market factor series on the + * shared day grid, producing aligned (portfolioDailyReturn, marketDailyReturn) + * pairs. Days missing on either side are dropped, never zero-filled. + */ +function intersectDailyReturns( + portfolioByDay: Map, + startDay: number, + endDay: number, + market: MarketFactorDay[] +): { portfolioReturns: number[]; marketReturns: number[] } { + const marketByDay = new Map() + for (const day of market) { + if (day.marketReturn !== null) { + marketByDay.set(day.date.getTime(), day.marketReturn) + } + } + + const portfolioReturns: number[] = [] + const marketReturns: number[] = [] + let prevValue: number | null = null + for (let day = startDay; day <= endDay; day += MS_PER_DAY) { + const value = portfolioByDay.get(day) + const mkt = marketByDay.get(day) + if (value !== undefined && prevValue !== null && mkt !== undefined) { + if (prevValue > 0) { + portfolioReturns.push((value - prevValue) / prevValue) + marketReturns.push(mkt) + } + } + if (value !== undefined) prevValue = value + } + + return { portfolioReturns, marketReturns } +} + +function snapshotHash(parts: { + portfolio: Map + rates: BenchmarkRatePoint[] + windowDays: number + weighting: string +}): string { + const sortedRates = [...parts.rates] + .sort((a, b) => + a.date.getTime() !== b.date.getTime() + ? a.date.getTime() - b.date.getTime() + : a.protocolName < b.protocolName + ? -1 + : 1 + ) + .map((r) => `${r.protocolName}:${r.apy.toFixed(9)}:${r.date.getTime()}`) + + const portfolio = Array.from(parts.portfolio.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([d, v]) => `${d}:${v.toFixed(9)}`) + + const canonical = JSON.stringify({ + portfolio, + rates: sortedRates, + windowDays: parts.windowDays, + weighting: parts.weighting, + }) + return 'sha256:' + crypto.createHash('sha256').update(canonical).digest('hex') +} + +/** + * Compute the full factor-exposure report for one user. + */ +export async function getFactorExposure( + userId: string, + options: FactorExposureOptions = {} +): Promise { + const now = options.now ?? new Date() + const weighting: FactorExposureWeighting = options.weighting ?? 'equal' + + const requestedWindow = options.windowDays ?? RETENTION_DAYS + const actualWindowDays = Math.min(requestedWindow, RETENTION_DAYS) + const rollingWindowDays = + options.rollingWindowDays ?? Math.min(30, actualWindowDays - 1) + + const endDay = Math.floor(now.getTime() / MS_PER_DAY) * MS_PER_DAY + const startDay = endDay - actualWindowDays * MS_PER_DAY + const fromDate = new Date(startDay) + + const [portfolioByDay, benchmarkRates] = await Promise.all([ + loadPortfolioValueSeries(userId, fromDate, now), + loadBenchmarkRates(fromDate), + ]) + + const factor = buildMarketFactorSeries({ + rates: benchmarkRates, + startDate: new Date(startDay), + endDate: new Date(endDay), + weighting, + }) + + const universeSize = new Set( + factor.series.flatMap((d) => d.sectors.map((s) => s.name)) + ).size + + const { portfolioReturns, marketReturns } = intersectDailyReturns( + portfolioByDay, + startDay, + endDay, + factor.series + ) + + const sampleCount = portfolioReturns.length + const inputHash = snapshotHash({ + portfolio: portfolioByDay, + rates: benchmarkRates, + windowDays: actualWindowDays, + weighting, + }) + + const caveats: string[] = [FACTOR_CAVEAT] + + const summary = + sampleCount >= MIN_FACTOR_SAMPLES + ? factorDecomposition({ + portfolioReturns, + marketReturns, + }) + : null + + let rolling: RollingBetaPoint[] = [] + if ( + sampleCount >= 2 && + rollingWindowDays > 0 && + rollingWindowDays < sampleCount + ) { + rolling = rollingBeta({ + portfolioReturns, + marketReturns, + windowSize: rollingWindowDays, + step: rollingWindowDays, + timestampsMs: portfolioReturns.map( + (_, i) => startDay + (i + 1) * MS_PER_DAY + ), + }) + } + + // < 2 windows: report the summary only, with a caveat (residual from the spec). + if (rolling.length < 2) { + caveats.push( + `Rolling window of ${rollingWindowDays}d over ${sampleCount} aligned observations leaves fewer than 2 windows; only the full-window summary is reported.` + ) + } + + // Degenerate market variance flag (all protocols moved together / forward-fill dominated). + if (summary && summary.beta === null && sampleCount >= MIN_FACTOR_SAMPLES) { + caveats.push( + 'The market factor shows effectively zero variance over this window (all protocols moved together or forward-fill dominated), so beta/R² are reported as null.' + ) + } + + return { + userId, + windowDays: requestedWindow, + actualWindowDays, + rollingWindowDays, + insufficientHistory: sampleCount < MIN_FACTOR_SAMPLES, + sampleCount, + rolling, + summary, + benchmark: { + weighting: factor.weighting === 'tvl' ? 'tvl' : 'equal', + universeSize, + tvlFallback: factor.tvlFallback, + }, + caveats, + inputHash, + computedAt: now.toISOString(), + } +} diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts index 36bb6b5..f820231 100644 --- a/src/routes/analytics.ts +++ b/src/routes/analytics.ts @@ -10,6 +10,10 @@ import { getPersistedUserRisk, } from '../analytics/riskService' import { getPortfolioCorrelation } from '../analytics/correlationService' +import { + getFactorExposure, + FactorExposureWeighting, +} from '../analytics/factorExposureService' import { getYieldBreakdown } from '../analytics/yieldCompositionService' import { RiskWindow } from '../analytics/metrics' import { @@ -651,6 +655,81 @@ router.get('/correlation', requireAuth, async (req: Request, res: Response) => { }) }) +/** + * GET /analytics/factor-exposure + * Rolling beta & market-factor exposure report (#352). + */ +router.get( + '/factor-exposure', + requireAuth, + async (req: Request, res: Response) => { + const userId = req.auth!.userId + + const factorExposureQuerySchema = z + .object({ + window: z.enum(['30d', '60d', '90d']).default('90d'), + rollingWindow: z.enum(['7d', '14d', '30d']).default('30d'), + weighting: z.enum(['equal', 'tvl']).default('equal'), + }) + .superRefine((data, ctx) => { + const windowDays = + data.window === '30d' ? 30 : data.window === '60d' ? 60 : 90 + const rollingDays = + data.rollingWindow === '7d' + ? 7 + : data.rollingWindow === '14d' + ? 14 + : 30 + if (rollingDays >= windowDays) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['rollingWindow'], + message: + 'rollingWindow must be shorter than window (yield snapshots are retained for 90 days).', + }) + } + }) + + const parsed = factorExposureQuerySchema.safeParse(req.query) + if (!parsed.success) { + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) + } + + const windowDays = + parsed.data.window === '30d' ? 30 : parsed.data.window === '60d' ? 60 : 90 + const rollingDays = + parsed.data.rollingWindow === '7d' + ? 7 + : parsed.data.rollingWindow === '14d' + ? 14 + : 30 + + const result = await getFactorExposure(userId, { + windowDays, + rollingWindowDays: rollingDays, + weighting: parsed.data.weighting as FactorExposureWeighting, + }) + + return res.status(200).json({ + userId, + window: parsed.data.window, + rollingWindow: parsed.data.rollingWindow, + weighting: result.benchmark.weighting, + actualWindowDays: result.actualWindowDays, + insufficientHistory: result.insufficientHistory, + sampleCount: result.sampleCount, + rolling: result.rolling, + summary: result.summary, + benchmark: result.benchmark, + caveats: result.caveats, + inputHash: result.inputHash, + computedAt: result.computedAt, + }) + } +) + /** * GET /analytics/yield-breakdown * Returns the base-vs-incentive composition and effective APY for the diff --git a/tests/integration/analytics/factor-exposure.integration.test.ts b/tests/integration/analytics/factor-exposure.integration.test.ts new file mode 100644 index 0000000..983166b --- /dev/null +++ b/tests/integration/analytics/factor-exposure.integration.test.ts @@ -0,0 +1,190 @@ +/** + * #352 — /analytics/factor-exposure route integration test. + * + * Mounts the REAL analytics router on a minimal Express app with only auth and + * the DB mocked, so a request travels through the real validators, the real + * DB-glue service (factorExposureService) and the real pure cores + * (benchmark.ts + factorExposure.ts). + * + * The portfolio fixture is engineered to TRACK the market factor, so the + * full-window summary should come out near beta ≈ 1 and R² ≈ 1 — validating + * the whole pipeline end-to-end, not just routing. + */ +const ownerUserId = '11111111-1111-4111-8111-111111111111' + +import request from 'supertest' +import express from 'express' + +jest.mock('../../../src/middleware/authenticate', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.userId = ownerUserId + req.auth = { userId: ownerUserId, walletAddress: 'GWALLET_OWNER' } + next() + }, +})) + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + logBackgroundJob: jest.fn(), + requestLogger: (_req: any, _res: any, next: any) => next(), +})) + +jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) +jest.mock('../../../src/config/env', () => ({ + config: { + attribution: { benchmarkProtocols: [] }, + }, +})) + +import db from '../../../src/db' +import analyticsRouter from '../../../src/routes/analytics' + +const mockDb = db as any + +const DAY = 24 * 60 * 60 * 1000 +const NOW = new Date('2026-08-17T00:00:00Z') +const YEAR_FRACTION_PER_DAY = 1 / 365.25 + +/** + * Build protocolRate rows where APY varies day-to-day (so the market factor has + * variance — required for a meaningful beta), and matching YieldSnapshot rows + * where the portfolio value compounds at exactly the market's daily return so + * the portfolio TRACKS the market (beta → 1, R² → 1). + */ +function buildFixtureConfig(days: number): { + protocolRates: any[] + snapshots: any[] + expectedBetaCloseTo: number +} { + const apyByDay: number[] = [] + for (let d = 0; d <= days; d++) apyByDay.push(5 + (d % 5)) // 5..9 cycling + + const protocolRates: any[] = [] + for (let d = 0; d <= days; d++) { + const fetchedAt = new Date(NOW.getTime() - (days - d) * DAY) + for (const name of ['Blend', 'Luma']) { + protocolRates.push({ + protocolName: name, + assetSymbol: 'USDC', + supplyApy: apyByDay[d], + tvl: 1000, + fetchedAt, + }) + } + } + + // market daily return on day d = mean of the two protocols' daily fraction + const marketRet = (d: number) => (apyByDay[d] / 100) * YEAR_FRACTION_PER_DAY + + const snapshots: any[] = [] + let value = 1000 + for (let d = 0; d <= days; d++) { + if (d > 0) value = value * (1 + marketRet(d)) + snapshots.push({ + positionId: `pos-${d}`, + snapshotAt: new Date(NOW.getTime() - (days - d) * DAY + 12 * 3600_000), + principalAmount: value.toFixed(6), + yieldAmount: '0', + }) + } + + return { protocolRates, snapshots, expectedBetaCloseTo: 1 } +} + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1/analytics', analyticsRouter) + return app +} + +function setupDb(days: number) { + const cfg = buildFixtureConfig(days) + mockDb.protocolRate = { + findMany: jest.fn().mockResolvedValue(cfg.protocolRates), + } + mockDb.yieldSnapshot = { + findMany: jest + .fn() + .mockImplementation(({ orderBy }: any) => + Promise.resolve( + orderBy?.snapshotAt === 'asc' ? cfg.snapshots : cfg.snapshots + ) + ), + } + // position is used by other analytics routes but not by factor-exposure; stub defensively. + mockDb.position = { findMany: jest.fn().mockResolvedValue([]) } +} + +describe('GET /api/v1/analytics/factor-exposure', () => { + it('returns a tracked portfolio with summary beta ≈ 1 and rolling windows', async () => { + setupDb(90) + const res = await request(buildApp()).get( + `/api/v1/analytics/factor-exposure?window=90d&rollingWindow=30d` + ) + + expect(res.status).toBe(200) + expect(res.body.userId).toBe(ownerUserId) + expect(res.body.insufficientHistory).toBe(false) + expect(res.body.sampleCount).toBeGreaterThan(30) + + // Summary: portfolio tracks the market -> beta ≈ 1, R² ≈ 1. + expect(res.body.summary).not.toBeNull() + expect(Math.abs(res.body.summary.beta - 1)).toBeLessThan(0.15) + expect(res.body.summary.rSquared).toBeGreaterThan(0.7) + + // Rolling: 90 samples, 30d non-overlapping -> ~3 windows. + expect(res.body.rolling.length).toBeGreaterThanOrEqual(2) + + // Benchmark universe + fixed caveat travel with the response. + expect(res.body.benchmark.universeSize).toBe(2) + expect(res.body.benchmark.weighting).toBe('equal') + expect(res.body.caveats).toContainEqual( + expect.stringContaining('not a traded index') + ) + + // Determinism: an input-snapshot hash is present and stable. + expect(res.body.inputHash).toMatch(/^sha256:/) + }) + + it('honours window and rollingWindow query params', async () => { + setupDb(90) + const res = await request(buildApp()).get( + `/api/v1/analytics/factor-exposure?window=30d&rollingWindow=7d` + ) + expect(res.status).toBe(200) + expect(res.body.window).toBe('30d') + expect(res.body.rollingWindow).toBe('7d') + }) + + it('rejects rollingWindow >= window (400)', async () => { + setupDb(90) + const res = await request(buildApp()).get( + `/api/v1/analytics/factor-exposure?window=30d&rollingWindow=30d` + ) + expect(res.status).toBe(400) + }) + + it('rejects an invalid rollingWindow enum (400)', async () => { + setupDb(90) + const res = await request(buildApp()).get( + `/api/v1/analytics/factor-exposure?window=90d&rollingWindow=999d` + ) + expect(res.status).toBe(400) + }) + + it('flags insufficientHistory when the portfolio has too little data', async () => { + setupDb(5) // only 6 days -> far below MIN_FACTOR_SAMPLES + const res = await request(buildApp()).get( + `/api/v1/analytics/factor-exposure?window=30d&rollingWindow=7d` + ) + expect(res.status).toBe(200) + expect(res.body.insufficientHistory).toBe(true) + expect(res.body.summary).toBeNull() + }) +}) diff --git a/tests/unit/analytics/benchmark.test.ts b/tests/unit/analytics/benchmark.test.ts new file mode 100644 index 0000000..b2df10c --- /dev/null +++ b/tests/unit/analytics/benchmark.test.ts @@ -0,0 +1,175 @@ +/** + * Tests for src/analytics/benchmark.ts — the canonical market-factor series (#352). + * + * Covers the equal-weight default (the v1 attribution definition), tvl + * weighting with equal fallback, forward-fill alignment, and a GOLDEN test + * that locks computeAttribution's output so the attribution.ts refactor (which + * now imports buildMarketFactorSeries instead of re-deriving the market) can + * never silently change an attribution number. + */ + +import { buildMarketFactorSeries } from '../../../src/analytics/benchmark' +import { computeAttribution } from '../../../src/analytics/attribution' + +const DAY = 24 * 60 * 60 * 1000 + +function isoDays(start: Date, count: number): Date[] { + return Array.from( + { length: count }, + (_, i) => new Date(start.getTime() + i * DAY) + ) +} + +describe('buildMarketFactorSeries', () => { + const now = new Date('2026-01-11T00:00:00Z') + + it('equal weighting: one equally-weighted sector per protocol, market return = mean daily return', () => { + // 3 protocols, flat 5% APY each -> each sector weight 1/3, returnFraction + // = (5/100) * (1/365.25) each day; market return = same single value. + const rates = [] + for (const d of isoDays(new Date('2026-01-01T00:00:00Z'), 10)) { + for (const name of ['Aave', 'Compound', 'Stellar DEX']) { + rates.push({ protocolName: name, assetSymbol: 'USDC', apy: 5, date: d }) + } + } + const { series, weighting, tvlFallback } = buildMarketFactorSeries({ + rates, + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: now, + weighting: 'equal', + }) + + expect(weighting).toBe('equal') + expect(tvlFallback).toBe(false) + const populated = series.filter((d) => d.sectors.length > 0) + expect(populated.length).toBeGreaterThan(0) + for (const day of populated) { + expect(day.sectors).toHaveLength(3) + for (const s of day.sectors) { + expect(s.weight).toBeCloseTo(1 / 3, 12) + expect(s.returnFraction).toBeCloseTo((5 / 100) * (1 / 365.25), 12) + } + // market return = sum(weight * rf) = (1/3+1/3+1/3) * rf = rf + expect(day.marketReturn).toBeCloseTo((5 / 100) * (1 / 365.25), 9) + } + }) + + it('tv1 weighting scales each day by tvl share and does not fall back when tvl present', () => { + const rates = [] + for (const d of isoDays(new Date('2026-01-01T00:00:00Z'), 10)) { + rates.push({ + protocolName: 'A', + assetSymbol: 'USDC', + apy: 5, + tvl: 300, + date: d, + }) + rates.push({ + protocolName: 'B', + assetSymbol: 'USDC', + apy: 5, + tvl: 100, + date: d, + }) + } + const { series, weighting, tvlFallback } = buildMarketFactorSeries({ + rates, + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: now, + weighting: 'tvl', + }) + expect(weighting).toBe('tvl') + expect(tvlFallback).toBe(false) + const populated = series.filter((d) => d.sectors.length > 0) + const a = populated[1].sectors.find((s) => s.name === 'A') + const b = populated[1].sectors.find((s) => s.name === 'B') + expect(a!.weight).toBeCloseTo(300 / 400, 12) + expect(b!.weight).toBeCloseTo(100 / 400, 12) + }) + + it('tvl requested but no tvl data → falls back to equal and is flagged', () => { + const rates = [] + for (const d of isoDays(new Date('2026-01-01T00:00:00Z'), 10)) { + for (const name of ['A', 'B']) { + rates.push({ protocolName: name, assetSymbol: 'USDC', apy: 5, date: d }) + } + } + const { series, weighting, tvlFallback } = buildMarketFactorSeries({ + rates, + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: now, + weighting: 'tvl', + }) + expect(weighting).toBe('equal') + expect(tvlFallback).toBe(true) + const populated = series.find((d) => d.sectors.length > 0)! + expect(populated.sectors[0].weight).toBeCloseTo(1 / 2, 12) + }) + + it('hole days return null marketReturn (no fabricated zero-fill)', () => { + // Only one protocol, only present on the first day -> subsequent days + // forward-fill it (so they ARE populated). To get a null day we use an + // empty raw series, which yields an empty series entirely. + const { series } = buildMarketFactorSeries({ + rates: [], + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: now, + }) + expect(series).toEqual([]) + }) +}) + +describe('golden: attribution imports the canonical benchmark (unchanged output)', () => { + const now = new Date('2026-01-11T00:00:00Z') + + it('computeAttribution still reconciles with exact, pinned benchmark return', () => { + const windowDays = 10 + const DAY_LOCAL = 24 * 60 * 60 * 1000 + const portfolioRows = [] + for (let d = 0; d <= windowDays; d++) { + portfolioRows.push({ + snapshotAt: new Date(now.getTime() - (windowDays - d) * DAY_LOCAL), + sector: 'Aave', + value: 1000 * Math.pow(1 + 0.0001, d), + }) + } + const benchmarkRates = [] + for (let d = 0; d <= windowDays; d++) { + const date = new Date(now.getTime() - (windowDays - d) * DAY_LOCAL) + for (const name of ['Aave', 'Compound']) { + benchmarkRates.push({ + protocolName: name, + assetSymbol: 'USDC', + apy: 5, // flat 5% + date, + }) + } + } + + const result = computeAttribution({ + portfolioRows, + benchmarkRates, + windowDays, + now, + benchmarkVersion: 'equal-weight-v1:test', + }) + + expect(result.includedPeriodCount).toBe(windowDays) + expect(result.reconciled).toBe(true) + + // GOLDEN: the benchmark series is derived from buildMarketFactorSeries so + // a future benchmark refactor that alters these exact numbers is caught. + const perDay = (5 / 100) * (1 / 365.25) // flat 5% APY daily fraction + expect(result.benchmarkReturn).toBeCloseTo( + Math.pow(1 + perDay, windowDays) - 1, + 6 + ) + + const aave = result.sectors.find((s) => s.sector === 'Aave')! + expect(aave.benchmarkWeight).toBeCloseTo(0.5, 12) // equal weight across 2 + expect(aave.benchmarkReturn).toBeCloseTo( + Math.pow(1 + perDay, windowDays) - 1, + 6 + ) + }) +}) diff --git a/tests/unit/analytics/factorExposure.test.ts b/tests/unit/analytics/factorExposure.test.ts new file mode 100644 index 0000000..8c3c5a6 --- /dev/null +++ b/tests/unit/analytics/factorExposure.test.ts @@ -0,0 +1,233 @@ +/** + * Pure-core tests for src/analytics/factorExposure.ts (#352). + * + * Fixture series exercise the three canonical cases required by the issue: + * - independent series → beta ≈ 0, R² ≈ 0 + * - portfolio == market → beta ≈ 1, R² ≈ 1 + * - degenerate (under-sampled / zero market variance) → all-null, never NaN + * Plus rolling-window mechanics (intersected slices, step, windowEndMs). + */ + +import { + rollingBeta, + factorDecomposition, + MIN_FACTOR_SAMPLES, + RollingBetaPoint, +} from '../../../src/analytics/factorExposure' + +/** N daily-return-style points that move but are independent of `x`. */ +function independentY(xs: number[], scale = 1e-4, offset = 0.001): number[] { + return xs.map((_, i) => Math.sin(i * 1.7) * scale + offset) +} + +describe('factorDecomposition', () => { + it('portfolio == market → beta ≈ 1 and R² ≈ 1', () => { + const market = Array.from( + { length: 60 }, + (_, i) => 0.0002 + 0.0001 * Math.sin(i) + ) + const portfolio = market.map((m) => m + 0) // identical + const d = factorDecomposition({ + portfolioReturns: portfolio, + marketReturns: market, + }) + expect(d.beta).not.toBeNull() + expect(Math.abs(d.beta! - 1)).toBeLessThan(1e-9) + expect(d.rSquared).not.toBeNull() + expect(d.rSquared!).toBeGreaterThan(0.9999) + expect(d.idiosyncraticVolShare!).toBeLessThan(1e-4) + }) + + it('independent series → beta ≈ 0 and R² ≈ 0', () => { + const market = Array.from( + { length: 60 }, + (_, i) => 0.0002 + 0.0001 * Math.sin(i) + ) + const portfolio = independentY(market) + const d = factorDecomposition({ + portfolioReturns: portfolio, + marketReturns: market, + }) + expect(d.beta).not.toBeNull() + expect(Math.abs(d.beta!)).toBeLessThan(0.5) + expect(d.rSquared!).toBeLessThan(0.4) + }) + + it('portfolio is a fixed multiple of market → beta ≈ that multiple', () => { + const market = Array.from( + { length: 60 }, + (_, i) => 0.0002 + 0.0003 * Math.sin(i) + ) + // beta = 2 (portfolio moves twice as much as the market) + const portfolio = market.map((m) => 2 * m) + const d = factorDecomposition({ + portfolioReturns: portfolio, + marketReturns: market, + }) + expect(Math.abs(d.beta! - 2)).toBeLessThan(1e-9) + expect(d.rSquared!).toBeGreaterThan(0.9999) + }) + + it('alpha annualizes the daily intercept (simple, x365.25)', () => { + const market = Array.from( + { length: 60 }, + (_, i) => 0.0002 + 0.0001 * Math.sin(i) + ) + // portfolio = market + 0.0001 fixed daily excess -> alpha = 0.0001 + const portfolio = market.map((m) => m + 0.0001) + const d = factorDecomposition({ + portfolioReturns: portfolio, + marketReturns: market, + }) + expect(d.alpha).not.toBeNull() + expect(Math.abs(d.alpha! - 0.0001)).toBeLessThan(1e-12) + expect(Math.abs(d.alphaAnnualized! - 0.0001 * 365.25)).toBeLessThan(1e-8) + }) + + it('under-sampled series → all-null statistics, finite sampleCount', () => { + const market = Array.from({ length: 5 }, (_, i) => 0.0001 * (i + 1)) + const d = factorDecomposition({ + portfolioReturns: market.map(() => 0.001), + marketReturns: market, + }) + expect(d.sampleCount).toBe(5) + expect(d.beta).toBeNull() + expect(d.alpha).toBeNull() + expect(d.alphaAnnualized).toBeNull() + expect(d.rSquared).toBeNull() + expect(d.idiosyncraticVolShare).toBeNull() + }) + + it('zero market variance → all-null beta, never NaN', () => { + const market = Array.from({ length: 60 }, () => 0.0003) // constant market + const portfolio = Array.from({ length: 60 }, (_, i) => 0.0001 * i) + const d = factorDecomposition({ + portfolioReturns: portfolio, + marketReturns: market, + }) + expect(Number.isNaN(d.beta as number)).toBe(false) + expect(d.beta).toBeNull() + expect(d.rSquared).toBeNull() + expect(Number.isFinite(d.sampleCount)).toBe(true) + }) + + it('mismatched lengths are intersected (truncated), not error-thrown', () => { + const market = Array.from({ length: 60 }, (_, i) => 0.0001 * (i + 1)) + const portfolio = Array.from({ length: 40 }, (_, i) => 0.0001 * (i + 1)) + const d = factorDecomposition({ + portfolioReturns: portfolio, + marketReturns: market, + }) + expect(d.sampleCount).toBe(40) + }) +}) + +describe('rollingBeta', () => { + const market = Array.from( + { length: 60 }, + (_, i) => 0.0002 + 0.0001 * Math.sin(i) + ) + + it('returns one point per non-overlapping window when step == windowSize', () => { + const ref = market // portfolio == market + const out = rollingBeta({ + portfolioReturns: ref, + marketReturns: market, + windowSize: 30, + step: 30, + }) + // 60 samples / 30 = 2 full windows + expect(out).toHaveLength(2) + for (const p of out) { + expect(p.sampleCount).toBe(30) + expect(p.beta).not.toBeNull() + } + }) + + it('with timestamps, windowEndMs is the last sample of each window', () => { + const ts = market.map((_, i) => (i + 1) * 86400000) + const out = rollingBeta({ + portfolioReturns: market, + marketReturns: market, + windowSize: 30, + step: 30, + timestampsMs: ts, + }) + expect(out[0].windowEndMs).toBe(ts[29]) + expect(out[1].windowEndMs).toBe(ts[59]) + }) + + it('overlapping windows when step < windowSize', () => { + const out = rollingBeta({ + portfolioReturns: market, + marketReturns: market, + windowSize: 40, + step: 10, + }) + // windows: [0,40) [10,50) [20,60) = 3 + expect(out).toHaveLength(3) + }) + + it('a rollingWindow that leaves < 2 windows → < 2 points (caller folds to summary)', () => { + // 60 samples, window 40 -> only 1 non-overlapping window + const out = rollingBeta({ + portfolioReturns: market, + marketReturns: market, + windowSize: 40, + step: 40, + }) + expect(out).toHaveLength(1) + }) + + it('degenerate window (zero market variance) → null beta, not NaN', () => { + const flatMarket = Array.from({ length: 60 }, () => 0.0003) + const mover = Array.from({ length: 60 }, (_, i) => 0.0001 * i) + const out = rollingBeta({ + portfolioReturns: mover, + marketReturns: flatMarket, + windowSize: 30, + step: 30, + }) + expect(out.length).toBe(2) + for (const p of out) { + expect(Number.isNaN(p.beta as number)).toBe(false) + expect(p.beta).toBeNull() + expect(p.rSquared).toBeNull() + } + }) + + it('under-sampled window (< MIN_FACTOR_SAMPLES) → null beta', () => { + const out = rollingBeta({ + portfolioReturns: market, + marketReturns: market, + windowSize: MIN_FACTOR_SAMPLES - 1, + }) + // 60 samples / 13 = 4 non-overlapping windows, each under-sampled. + expect(out.length).toBe(4) + expect(out[0].sampleCount).toBe(MIN_FACTOR_SAMPLES - 1) + expect(out[0].beta).toBeNull() + }) + + it('empty input returns no windows', () => { + const out = rollingBeta({ + portfolioReturns: [], + marketReturns: [], + windowSize: 30, + }) + expect(out).toEqual([]) + }) + + it('shapes are stable and finite', () => { + const out: RollingBetaPoint[] = rollingBeta({ + portfolioReturns: market.map((m) => m), + marketReturns: market.map((m) => m), + windowSize: 30, + step: 30, + timestampsMs: market.map((_, i) => (i + 1) * 86400000), + }) + const p = out[0] + expect(Number.isFinite(p.windowEndMs)).toBe(true) + expect(p.alphaAnnualized).not.toBeNull() + expect(p.idiosyncraticVolShare).not.toBeNull() + }) +})