Problem Statement
src/analytics/metrics.ts computes a single beta "vs an exogenous benchmark series," but nothing supplies that benchmark, so beta is effectively always null. Meanwhile src/analytics/attribution.ts already defines a v1 "market" — the equal-weighted average of available ProtocolRate APY history — and proves a Brinson identity against it. This issue connects those: a rolling beta and market-factor exposure report that measures how much of a portfolio's yield movement is explained by the DeFi-yield "market factor" versus idiosyncratic protocol selection, computed on a rolling window so a user can see their exposure changing over time, not just a point estimate.
Current State
src/analytics/metrics.ts — computeAllMetrics accepts an optional benchmark and returns beta (currently unfed); inferPeriodsPerYear; null-on-degenerate contract.
src/analytics/attribution.ts — v1 benchmark = equal-weighted mean of available ProtocolRate APY per day; accepts RawProtocolRatePoint[] so a real index can be swapped in; Cariño multi-period linking; reconciliation tolerance.
src/analytics/estimation.ts — buildDailyRateSeries / aligned daily grid / MIN_ALIGNED_OBSERVATIONS.
src/analytics/riskService.ts — DB glue, 90d retention, PortfolioRiskAggregate.
src/jobs/attribution.ts — the DB-reading job for attribution.
docs/PERFORMANCE_ATTRIBUTION.md, docs/STRATEGY_MARKETPLACE.md §2.
Proposed Solution
1. Canonical benchmark series (src/analytics/benchmark.ts, new)
buildMarketFactorSeries(rawRates, { universe?, weighting: 'equal' | 'tvl' }) → a daily benchmark return series (not level) on the aligned grid, reusing estimation.ts alignment and the attribution.ts equal-weight definition as the default. This is the single place the "market" is defined for all of src/analytics/; attribution.ts is refactored to import it so there is one definition, not two.
- Pluggable weighting;
tvl weighting uses poolMetrics/liquidity snapshots when available, else falls back to equal with a flag.
2. Pure core (src/analytics/factorExposure.ts)
rollingBeta(portfolioReturns, marketReturns, windowSize, step) → [{ windowEndMs, beta, alpha, rSquared, sampleCount }]; each window uses OLS of portfolio return on market return; null entries for windows with < MIN_SAMPLES or zero market variance.
factorDecomposition(portfolioReturns, marketReturns) over the full window → { beta, alpha (annualized), rSquared, idiosyncraticVolShare } — "78% of your yield variance tracks the DeFi-yield market; 22% is your protocol selection."
- Deterministic, zero I/O, fixture-tested (independent series → beta ~0; portfolio = market → beta ~1, R² ~1; degenerate → null).
3. DB glue + API
- Portfolio return series from
YieldSnapshot value buckets (principal + yield), same as riskService.ts; market series from benchmark.ts over ProtocolRate.
GET /api/v1/analytics/factor-exposure?window=90d&rollingWindow=30d → { rolling: [...], summary: { beta, alpha, rSquared, idiosyncraticVolShare }, benchmark: { weighting, universeSize }, insufficientHistory, caveats }.
- Fixed 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."
- Owner-scoped, rate-limited;
docs/openapi.yaml + a section in docs/PERFORMANCE_ATTRIBUTION.md.
Good first issue candidate: the pure rollingBeta / OLS core is a well-scoped, well-specified unit-testable piece; the benchmark unification and wiring can follow.
Edge Cases & Failure Modes
- Retention: 90d cap;
rollingWindow must be < window; a rollingWindow that leaves < 2 windows returns the summary only with a caveat.
- Market variance ~0 (all protocols moved together, or forward-fill dominated): window
beta = null; a metric flags degenerate windows.
- Portfolio with one protocol: beta vs. an equal-weighted market that includes that protocol is still meaningful but trends toward the protocol's own weight in the benchmark; documented, not hidden.
- NaN safety: OLS denominator zero →
null; no Infinity/NaN ever returned (contract inherited from metrics.ts).
- Alignment: portfolio and market series must be on the same daily grid with the same days present; mismatched days are dropped, not zero-filled, and
sampleCount reflects the intersection.
- Determinism: protocols sorted;
asOf explicit; input-snapshot hash returned.
- attribution.ts refactor: importing the shared benchmark must not change existing attribution outputs — a golden test locks current attribution results before/after.
Security & Privacy Considerations
- Reads the caller's
YieldSnapshot-derived series + public ProtocolRate; no cross-user data.
- Benchmark universe is public protocols only; no per-user or per-strategy universe leakage.
- Owner-scoped, sub-account
VIEW gated, rate-limited.
Out of Scope
- Multi-factor models (size/momentum/quality analogues) — single market factor here.
- A real tradeable index product.
- Price beta (no price series; this is yield co-movement, stated plainly).
- Using beta as an agent input.
Suggested Implementation Plan
src/analytics/benchmark.ts — buildMarketFactorSeries; refactor attribution.ts to import it; golden test locking attribution outputs.
src/analytics/factorExposure.ts — rollingBeta (OLS) + factorDecomposition; fixture unit tests.
- DB glue: portfolio + market return series on a shared grid.
GET /api/v1/analytics/factor-exposure + validator (rollingWindow < window) + fixed caveat.
docs/PERFORMANCE_ATTRIBUTION.md section + docs/openapi.yaml; degenerate-window metric.
Acceptance Criteria
Problem Statement
src/analytics/metrics.tscomputes a singlebeta"vs an exogenous benchmark series," but nothing supplies that benchmark, sobetais effectively alwaysnull. Meanwhilesrc/analytics/attribution.tsalready defines a v1 "market" — the equal-weighted average of availableProtocolRateAPY history — and proves a Brinson identity against it. This issue connects those: a rolling beta and market-factor exposure report that measures how much of a portfolio's yield movement is explained by the DeFi-yield "market factor" versus idiosyncratic protocol selection, computed on a rolling window so a user can see their exposure changing over time, not just a point estimate.Current State
src/analytics/metrics.ts—computeAllMetricsaccepts an optional benchmark and returnsbeta(currently unfed);inferPeriodsPerYear; null-on-degenerate contract.src/analytics/attribution.ts— v1 benchmark = equal-weighted mean of availableProtocolRateAPY per day; acceptsRawProtocolRatePoint[]so a real index can be swapped in; Cariño multi-period linking; reconciliation tolerance.src/analytics/estimation.ts—buildDailyRateSeries/ aligned daily grid /MIN_ALIGNED_OBSERVATIONS.src/analytics/riskService.ts— DB glue, 90d retention,PortfolioRiskAggregate.src/jobs/attribution.ts— the DB-reading job for attribution.docs/PERFORMANCE_ATTRIBUTION.md,docs/STRATEGY_MARKETPLACE.md§2.Proposed Solution
1. Canonical benchmark series (
src/analytics/benchmark.ts, new)buildMarketFactorSeries(rawRates, { universe?, weighting: 'equal' | 'tvl' })→ a daily benchmark return series (not level) on the aligned grid, reusingestimation.tsalignment and theattribution.tsequal-weight definition as the default. This is the single place the "market" is defined for all ofsrc/analytics/;attribution.tsis refactored to import it so there is one definition, not two.tvlweighting usespoolMetrics/liquidity snapshots when available, else falls back toequalwith a flag.2. Pure core (
src/analytics/factorExposure.ts)rollingBeta(portfolioReturns, marketReturns, windowSize, step)→[{ windowEndMs, beta, alpha, rSquared, sampleCount }]; each window uses OLS of portfolio return on market return;nullentries for windows with< MIN_SAMPLESor zero market variance.factorDecomposition(portfolioReturns, marketReturns)over the full window →{ beta, alpha (annualized), rSquared, idiosyncraticVolShare }— "78% of your yield variance tracks the DeFi-yield market; 22% is your protocol selection."3. DB glue + API
YieldSnapshotvalue buckets (principal + yield), same asriskService.ts; market series frombenchmark.tsoverProtocolRate.GET /api/v1/analytics/factor-exposure?window=90d&rollingWindow=30d→{ rolling: [...], summary: { beta, alpha, rSquared, idiosyncraticVolShare }, benchmark: { weighting, universeSize }, insufficientHistory, caveats }.docs/openapi.yaml+ a section indocs/PERFORMANCE_ATTRIBUTION.md.Good first issue candidate: the pure
rollingBeta/ OLS core is a well-scoped, well-specified unit-testable piece; the benchmark unification and wiring can follow.Edge Cases & Failure Modes
rollingWindowmust be< window; arollingWindowthat leaves < 2 windows returns the summary only with a caveat.beta = null; a metric flags degenerate windows.null; noInfinity/NaNever returned (contract inherited frommetrics.ts).sampleCountreflects the intersection.asOfexplicit; input-snapshot hash returned.Security & Privacy Considerations
YieldSnapshot-derived series + publicProtocolRate; no cross-user data.VIEWgated, rate-limited.Out of Scope
Suggested Implementation Plan
src/analytics/benchmark.ts—buildMarketFactorSeries; refactorattribution.tsto import it; golden test locking attribution outputs.src/analytics/factorExposure.ts—rollingBeta(OLS) +factorDecomposition; fixture unit tests.GET /api/v1/analytics/factor-exposure+ validator (rollingWindow < window) + fixed caveat.docs/PERFORMANCE_ATTRIBUTION.mdsection +docs/openapi.yaml; degenerate-window metric.Acceptance Criteria
buildMarketFactorSeriesdefines "the market" for all ofsrc/analytics/;attribution.tsimports it with a golden test proving unchanged attribution outputrollingBetareturns per-window{ beta, alpha, rSquared, sampleCount }via OLS,nullfor under-sampled or zero-variance windowsfactorDecompositionreturns full-window beta/alpha/R²/idiosyncratic-vol-shareGET /api/v1/analytics/factor-exposureis owner-scoped, retention-bounded,rollingWindow < windowenforced, with a mandatory yield-co-movement caveatdocs/PERFORMANCE_ATTRIBUTION.md+docs/openapi.yamlupdated; unit + integration tests green