Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 75 additions & 11 deletions docs/PERFORMANCE_ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
149 changes: 149 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 27 additions & 29 deletions src/analytics/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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)
Expand All @@ -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<string>([
...Object.keys(prevValues),
...benchmarkDay.protocols.map((p) => p.name),
...benchmarkDay.sectors.map((s) => s.name),
])

const sectorStates: SectorState[] = []
Expand All @@ -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) {
Expand All @@ -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)
}
Expand Down
Loading
Loading