diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 7ff4b32..4af6d96 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -11,6 +11,7 @@ - **[TAX_REPORT.md](TAX_REPORT.md)** - Tax reporting & FIFO cost-basis lot tracking (#284) - **[STRATEGY_MARKETPLACE.md](STRATEGY_MARKETPLACE.md)** - Strategy marketplace / opt-in copy-trading: metric formula, eligibility gate, privacy & custody boundaries (#285) - **[PORTFOLIO_OPTIMIZATION.md](PORTFOLIO_OPTIMIZATION.md)** - Portfolio optimization & allocation suggestions: objective, λ mapping, estimation method, advisory invariant, limitations (#322) +- **[PERFORMANCE_ATTRIBUTION.md](PERFORMANCE_ATTRIBUTION.md)** - Benchmark-relative Brinson attribution: allocation/selection effects, Cariño linking, benchmark definition, `vsBenchmark` on the marketplace (#320) ### For DevOps/Deployment diff --git a/docs/PERFORMANCE_ATTRIBUTION.md b/docs/PERFORMANCE_ATTRIBUTION.md new file mode 100644 index 0000000..f164b2e --- /dev/null +++ b/docs/PERFORMANCE_ATTRIBUTION.md @@ -0,0 +1,183 @@ +# Performance Attribution & Benchmark-Relative Return Reporting (#320) + +Answers "why" a portfolio (or a published strategy) returned what it did: a +Brinson-style, benchmark-relative decomposition into an **allocation +effect** (did the money sit in the right protocols?) and a **selection +effect** (did the chosen protocol beat its peers?), linked across a 30- or +90-day window. Same pure-math-plus-persisted-job architecture as +[STRATEGY_MARKETPLACE.md](./STRATEGY_MARKETPLACE.md). + +--- + +## 1. The pure core + +Lives in `src/analytics/attribution.ts` — zero I/O, unit tested against +fixture series. `src/jobs/attribution.ts` is the only thing that reads the DB +and calls into it. + +### The same correctness trap as strategyMetrics.ts + +`YieldSnapshot.apy` is a cumulative running average, never a period return +(see STRATEGY_MARKETPLACE.md §2). Attribution is computed from portfolio +**value** (`principalAmount + yieldAmount`), bucketed per protocol per day — +never from the raw `apy` column. `ProtocolRate.supplyApy` **is** a rate quote +and is the correct input for the benchmark side, matching +`src/analytics/estimation.ts`. + +### 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. + +### Sectors, v1 + +A "sector" is a protocol name. A protocol-to-sector map (grouping several +protocols into one sector, e.g. "lending" vs. "DEX LP") is a natural v2 +extension, deliberately out of scope here. + +### The Brinson model, interaction folded into selection + +For sector _i_ in period _t_, with portfolio weight/return `(w_p, r_p)` and +benchmark weight/return `(w_b, r_b)`: + +``` +allocationEffect_i = (w_p,i - w_b,i) * r_b,i +selectionEffect_i = w_p,i * (r_p,i - r_b,i) +``` + +This is the classic three-term Brinson-Hood-Beebower model with the +interaction term folded into selection — a documented, deliberate choice. +Folding it in keeps the decomposition exact for a single period: + +``` +allocationEffect_i + selectionEffect_i = w_p,i * r_p,i - w_b,i * r_b,i +``` + +Summed over the full sector universe, the right side telescopes to `R_p - R_b` +— the whole period's excess return — with no separate interaction term to +explain to a user. + +**Weight guards, never NaN**: a sector the portfolio does not hold has +`w_p,i = 0`. Its `portfolioReturn` may be `null` (nothing to divide by), so +`selectionEffect` is guarded on `w_p,i > 0` rather than on +`portfolioReturn !== null` — `0 * null` would otherwise become `NaN` in JS +instead of the correct `0`. + +**An empty-to-funded period** (portfolio started with nothing) is handled by +construction rather than a special "skip" branch: with `w_p,i = 0` for every +sector, the period's whole-portfolio return is exactly `0` (a deposit into an +empty portfolio is not a return), while the benchmark side still credits a +pure allocation effect for whatever the market did during the gap — see the +"empty-to-funded" fixture test. + +### Multi-period linking: Cariño smoothing + +Period effects are additive per period but returns compound multiplicatively, +so naively summing daily effects across a window does not reconcile to the +window's actual excess return. This module uses the standard +**Cariño (1999) logarithmic smoothing**: each period's effects are scaled by +`k_t / K`, where `k_t` derives from that period's own returns and `K` from the +whole window's compounded returns. This makes + +``` +linkedAllocation + linkedSelection + linkedUnattributed = R_P - R_B +``` + +hold exactly (mod floating-point epsilon) over the whole window. Periods run +on a **daily** grid (matching `buildDailyRateSeries`'s existing gap policy), +not the hourly cadence snapshots are captured at. + +### Degenerate cases: null/unattributed, never Infinity + +- A sector with no benchmark data for a period cannot be split into + allocation/selection; its portfolio contribution flows into that period's + `unattributed` figure instead of being dropped or guessed at. +- A period whose compounded return implies a total wipeout (`1 + R <= 0`) + excludes that period from the linked sum; the resulting gap is reported + explicitly (`reconciliationGap`, `reconciled: false`) rather than fudged. +- Zero included periods return a fully null/zero result — never a + divide-by-zero. + +`RECONCILIATION_TOLERANCE` (`1e-6`) bounds only floating-point accumulation +over many periods — it is not permission to silently absorb real gaps from +missing data, which flow through `unattributedEffect` instead. + +--- + +## 2. Precomputation + persistence + +`src/jobs/attribution.ts` fetches the whole window's `YieldSnapshot` and +`ProtocolRate` history **once per run** (not once per user), computes +attribution per user and per published strategy, and upserts into: + +- `PortfolioAttribution` — one row per `(userId, windowDays)`. +- `StrategyAttribution` — one row per `(publishedStrategyId, windowDays)`, + computed for every `PublishedStrategy` regardless of `isPublished` (so + re-publishing shows a benchmark-relative figure immediately, mirroring + `PublishedStrategyMetric`). + +Windows are **30d and 90d only** — `YieldSnapshot` retention is 90 days +(`src/agent/snapshotter.ts`), so a longer window has no data behind it. + +`scripts/backfill-attribution.ts` recomputes on demand: a fresh deploy of the +migration (no rows exist yet), a benchmark-config change, or a manual repair. +Idempotent — every write is an upsert keyed on `(subject, windowDays)`. + +### Configuration + +| Env var | Default | Meaning | +| --------------------------------- | ------- | ---------------------------------------------------------------------| +| `ATTRIBUTION_INTERVAL_MS` | `21600000` (6h) | Recompute cadence, matching `strategyMarketplace`. | +| `ATTRIBUTION_BENCHMARK_PROTOCOLS` | unset (= every protocol) | Comma-separated protocol-name subset for the benchmark. | + +--- + +## 3. API + +`GET /api/v1/analytics/attribution?window=30d\|90d` — authenticated, +owner-scoped via `req.auth.userId` (never a path param). Reads the persisted +`PortfolioAttribution` row; returns `{ computed: false }` (still a 200, not a +404) when nothing has been precomputed yet — a normal state for a new +account, mirroring the `{ follow: null }` convention in the strategy +marketplace. + +The strategy marketplace (`GET /api/v1/strategies/marketplace`) gains +`vsBenchmark` on each entry: `portfolioReturn - benchmarkReturn` from +`StrategyAttribution`, merged onto the page of `PublishedStrategyMetric` rows +by strategy id — a bounded lookup over the current page only, **not** a +second sort key and **not** a per-request recompute. `null` when attribution +has not been computed yet for that strategy/window. + +Both responses report only relative figures (returns, weights, effects) — +never an absolute currency amount — and a strategy's report is derived from +the publisher's own aggregates only, matching the anonymization boundary in +STRATEGY_MARKETPLACE.md §1. + +--- + +## 4. Consistency + +`tests/unit/analytics/attribution.test.ts` includes an anti-divergence test: +the whole-portfolio value produced by attribution's per-sector series must +equal `strategyMetrics.bucketByInstant`'s output for the same rows at the +same instant. If the two ever diverge, that test fails — attribution and the +marketplace's Sharpe/APY figures must always agree on what "portfolio value" +means. + +--- + +## 5. Out of scope (deliberately) + +1. A live external benchmark index feed — the module accepts an exogenous + `RawProtocolRatePoint[]` series; sourcing a real index is deferred. +2. Transaction-cost attribution (cost drag within selection). +3. Currency-hedging attribution. +4. A protocol-to-sector grouping map (v1 sector = protocol). diff --git a/docs/STRATEGY_MARKETPLACE.md b/docs/STRATEGY_MARKETPLACE.md index de65e74..c268c15 100644 --- a/docs/STRATEGY_MARKETPLACE.md +++ b/docs/STRATEGY_MARKETPLACE.md @@ -43,8 +43,11 @@ The publisher's `userId` **is** loaded in `followStrategy` — solely to compare against the caller for the self-follow check. It never reaches a response. Displayed statistics are derived from the publisher's own aggregates only. The -response carries `apy` / `sharpe` / `trackRecordDays` / `sampleCount` and never -an absolute currency amount. +response carries `apy` / `sharpe` / `trackRecordDays` / `sampleCount` / +`vsBenchmark` and never an absolute currency amount. `vsBenchmark` (#320) is a +relative figure — the strategy's portfolio return minus the benchmark's return +over the window — sourced from `StrategyAttribution`; see +docs/PERFORMANCE_ATTRIBUTION.md. --- @@ -224,6 +227,7 @@ it never influences a decision. | `PublishedStrategy` | One row per user (`userId @unique`) — publish always acts on the caller, so re-publishing upserts. `configVersion` bumps only on a **material** change to the three agent-relevant keys; a label edit is cosmetic. | | `StrategyFollow` | Carries its own `appliedConfig` **snapshot**, not a live read-through. `publishedStrategyId` is nullable with `onDelete: SetNull` so a follower survives the publisher deleting their account. | | `PublishedStrategyMetric` | One row per `(strategy, window)`. Precomputed because the leaderboard must `ORDER BY` the score with `skip`/`take` (a JS-computed value cannot be ordered in SQL) and recomputing every publisher's history per request would be a DoS vector. Same precedent as `ProtocolRiskScore` + `src/jobs/protocolRiskScoring.ts`. | +| `StrategyAttribution` | One row per `(strategy, window)` (#320). Supplies `vsBenchmark` on marketplace entries — merged onto the `PublishedStrategyMetric` page in `getMarketplace` by id, never used for the SQL sort itself. See docs/PERFORMANCE_ATTRIBUTION.md. | **Partial unique index** (raw SQL in the migration — Prisma cannot express partial uniques): diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d231e25..90a6ead 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1844,6 +1844,87 @@ paths: '400': $ref: '#/components/responses/BadRequest' + /api/v1/analytics/attribution: + get: + tags: [analytics] + operationId: getPerformanceAttribution + summary: Get benchmark-relative performance attribution + description: > + Returns a Brinson-style, benchmark-relative decomposition of the + caller's OWN portfolio return into allocation and selection effects, + precomputed by a scheduled job and read from the persisted + `PortfolioAttribution` row — never recomputed per request. + + + The benchmark (v1) is the equal-weighted average of available + protocol APY history (or a configured subset); `benchmarkVersion` + names which one produced this report. `reconciled` is false, and + `reconciliationGap` non-zero, when the linked allocation + selection + + unattributed figures could not be made to match the actual + portfolio-vs-benchmark excess return (e.g. a period with a total + wipeout) — the gap is reported explicitly rather than silently + absorbed. + + + `computed: false` (still a 200, not a 404) means nothing has been + precomputed yet for this user/window — a normal state for a new + account, not a missing resource. + + + `window` accepts `30d` and `90d` only, for the same reason as the + strategy marketplace: yield snapshots are retained for 90 days. + security: + - BearerAuth: [] + parameters: + - in: query + name: window + schema: + type: string + enum: ['30d', '90d'] + default: '30d' + description: Statistics window. Longer windows are rejected (90-day snapshot retention). + responses: + '200': + description: The caller's performance attribution, or an unattributed placeholder + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioAttributionResponse' + examples: + computed: + value: + userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + window: 30d + computed: true + windowDays: 30 + portfolioReturn: 0.083 + benchmarkReturn: 0.061 + vsBenchmark: 0.022 + allocationEffect: 0.009 + selectionEffect: 0.013 + unattributedEffect: 0 + reconciliationGap: 0.0000001 + reconciled: true + benchmarkVersion: 'equal-weight-v1:all' + sectors: + - sector: Aave + portfolioWeight: 0.6 + benchmarkWeight: 0.33 + portfolioReturn: 0.09 + benchmarkReturn: 0.05 + allocationEffect: 0.006 + selectionEffect: 0.011 + computedAt: '2026-06-30T00:00:00.000Z' + notYetComputed: + value: + userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + window: 30d + computed: false + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + # ── Stellar ──────────────────────────────────────────────────────────────── /api/v1/stellar/metrics: get: @@ -4518,6 +4599,16 @@ components: computedAt: type: string format: date-time + vsBenchmark: + type: number + nullable: true + description: > + Benchmark-relative return (#320): this strategy's portfolio + return minus the benchmark's return over the window, as a decimal + fraction. Null when attribution has not been computed yet for this + strategy/window. Lets a leaderboard viewer distinguish a strategy + that beat the market from one that merely took on more risk to + match it — apy/sharpe alone cannot. StrategyMarketplaceResponse: type: object @@ -4540,6 +4631,99 @@ components: items: $ref: '#/components/schemas/StrategyMarketplaceEntry' + SectorAttribution: + type: object + description: > + One protocol's (sector's) contribution within an attribution report + (#320). A sector may appear with a benchmark weight but zero + portfolio weight (the portfolio never held it) or vice versa — both + are reported, never dropped. + required: + - sector + - portfolioWeight + - benchmarkWeight + - allocationEffect + - selectionEffect + properties: + sector: + type: string + description: Protocol name — the v1 definition of "sector". + portfolioWeight: + type: number + description: Time-averaged share of the portfolio held in this sector over the window (0-1). + benchmarkWeight: + type: number + description: Time-averaged benchmark weight for this sector over the window (0-1). + portfolioReturn: + type: number + nullable: true + description: Compounded return of this sector within the portfolio; null if never held with a computable return. + benchmarkReturn: + type: number + nullable: true + description: Compounded benchmark return for this sector; null if the benchmark never had data for it. + allocationEffect: + type: number + description: Linked contribution from over/underweighting this sector relative to the benchmark. + selectionEffect: + type: number + description: Linked contribution from this sector's own return beating or lagging the benchmark's. + + PortfolioAttributionResponse: + type: object + description: > + Benchmark-relative Brinson attribution for one subject/window (#320). + Effects are relative figures only — never an absolute currency + amount, matching the anonymization discipline used for published + strategies. + required: [userId, window, computed] + properties: + userId: + type: string + window: + type: string + enum: ['30d', '90d'] + computed: + type: boolean + description: False when nothing has been precomputed yet for this user/window — a normal state, not an error. + windowDays: + type: integer + enum: [30, 90] + portfolioReturn: + type: number + description: Compounded portfolio return over the window (decimal fraction). + benchmarkReturn: + type: number + description: Compounded benchmark return over the window (decimal fraction). + vsBenchmark: + type: number + description: portfolioReturn - benchmarkReturn. + allocationEffect: + type: number + description: Linked total allocation effect across the window. + selectionEffect: + type: number + description: Linked total selection effect across the window (interaction folded in — see docs/PERFORMANCE_ATTRIBUTION.md). + unattributedEffect: + type: number + description: Linked contribution from sectors/periods with no benchmark comparator — never fabricated as allocation or selection. + reconciliationGap: + type: number + description: (portfolioReturn - benchmarkReturn) - (allocationEffect + selectionEffect + unattributedEffect). + reconciled: + type: boolean + description: False when reconciliationGap exceeds the documented tolerance — reported explicitly rather than silently forced to match. + benchmarkVersion: + type: string + description: Which benchmark definition/protocol subset produced this report. + sectors: + type: array + items: + $ref: '#/components/schemas/SectorAttribution' + computedAt: + type: string + format: date-time + StrategyFollow: type: object required: [id, appliedConfig, appliedConfigVersion, appliedAt, followedAt] diff --git a/prisma/migrations/20260819000000_add_performance_attribution/migration.sql b/prisma/migrations/20260819000000_add_performance_attribution/migration.sql new file mode 100644 index 0000000..9ca0971 --- /dev/null +++ b/prisma/migrations/20260819000000_add_performance_attribution/migration.sql @@ -0,0 +1,69 @@ +-- Performance attribution & benchmark-relative return reporting (#320). +-- +-- Adds two precomputed-metric tables, one row per (subject, windowDays), +-- mirroring published_strategy_metrics: the API and the marketplace read +-- these rows rather than recomputing a window's worth of daily YieldSnapshot + +-- ProtocolRate history on every request. All the math lives in +-- src/analytics/attribution.ts (pure, zero I/O); src/jobs/attribution.ts is +-- the only thing that writes these tables. +-- +-- "sectorBreakdown" is JSONB rather than a child table on purpose: it is +-- small (bounded by the protocol universe), never filtered/sorted on its own, +-- and always read as a whole alongside the row that owns it — same precedent +-- as allocation_suggestions."weights"/"frontier". + +-- CreateTable +CREATE TABLE "portfolio_attributions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "windowDays" INTEGER NOT NULL, + "portfolioReturn" DOUBLE PRECISION NOT NULL, + "benchmarkReturn" DOUBLE PRECISION NOT NULL, + "allocationEffect" DOUBLE PRECISION NOT NULL, + "selectionEffect" DOUBLE PRECISION NOT NULL, + "unattributedEffect" DOUBLE PRECISION NOT NULL, + "reconciliationGap" DOUBLE PRECISION NOT NULL, + "reconciled" BOOLEAN NOT NULL DEFAULT false, + "benchmarkVersion" TEXT NOT NULL, + "sectorBreakdown" JSONB NOT NULL, + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "portfolio_attributions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "strategy_attributions" ( + "id" TEXT NOT NULL, + "publishedStrategyId" TEXT NOT NULL, + "windowDays" INTEGER NOT NULL, + "portfolioReturn" DOUBLE PRECISION NOT NULL, + "benchmarkReturn" DOUBLE PRECISION NOT NULL, + "allocationEffect" DOUBLE PRECISION NOT NULL, + "selectionEffect" DOUBLE PRECISION NOT NULL, + "unattributedEffect" DOUBLE PRECISION NOT NULL, + "reconciliationGap" DOUBLE PRECISION NOT NULL, + "reconciled" BOOLEAN NOT NULL DEFAULT false, + "benchmarkVersion" TEXT NOT NULL, + "sectorBreakdown" JSONB NOT NULL, + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "strategy_attributions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "portfolio_attributions_userId_idx" ON "portfolio_attributions"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "portfolio_attributions_userId_windowDays_key" ON "portfolio_attributions"("userId", "windowDays"); + +-- CreateIndex +CREATE INDEX "strategy_attributions_publishedStrategyId_idx" ON "strategy_attributions"("publishedStrategyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "strategy_attributions_publishedStrategyId_windowDays_key" ON "strategy_attributions"("publishedStrategyId", "windowDays"); + +-- AddForeignKey +ALTER TABLE "portfolio_attributions" ADD CONSTRAINT "portfolio_attributions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "strategy_attributions" ADD CONSTRAINT "strategy_attributions_publishedStrategyId_fkey" FOREIGN KEY ("publishedStrategyId") REFERENCES "published_strategies"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260819000000_add_performance_attribution/rollback.sql b/prisma/migrations/20260819000000_add_performance_attribution/rollback.sql new file mode 100644 index 0000000..4cdbfbf --- /dev/null +++ b/prisma/migrations/20260819000000_add_performance_attribution/rollback.sql @@ -0,0 +1,17 @@ +-- Rollback for 20260819000000_add_performance_attribution +-- Drops the performance-attribution tables (#320). +-- +-- Safe to run before or after deploying the reverted application code: both +-- tables are read-only through src/routes/analytics.ts and the marketplace +-- mapper, and both are written only by src/jobs/attribution.ts. Removing them +-- makes GET /api/v1/analytics/attribution start returning 404/empty and drops +-- `vsBenchmark` from marketplace entries; no other feature reads these tables +-- and no funds or positions are affected. + +ALTER TABLE "strategy_attributions" DROP CONSTRAINT IF EXISTS "strategy_attributions_publishedStrategyId_fkey"; + +ALTER TABLE "portfolio_attributions" DROP CONSTRAINT IF EXISTS "portfolio_attributions_userId_fkey"; + +DROP TABLE IF EXISTS "strategy_attributions"; + +DROP TABLE IF EXISTS "portfolio_attributions"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60fd57e..c3d7723 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -185,6 +185,7 @@ model User { parentSubAccounts SubAccount[] @relation("ParentOf") childSubAccounts SubAccount[] @relation("ChildOf") allocationSuggestions AllocationSuggestion[] + portfolioAttributions PortfolioAttribution[] @@map("users") } @@ -873,9 +874,10 @@ model PublishedStrategy { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - followers StrategyFollow[] - metrics PublishedStrategyMetric[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + followers StrategyFollow[] + metrics PublishedStrategyMetric[] + attributions StrategyAttribution[] @@index([isPublished]) @@map("published_strategies") @@ -947,6 +949,68 @@ model PublishedStrategyMetric { @@map("published_strategy_metrics") } +/// Precomputed performance attribution for one user's own portfolio (#320). +/// +/// One row per (userId, windowDays), mirroring PublishedStrategyMetric's +/// rationale: this must be readable by the API without recomputing a window's +/// worth of daily YieldSnapshot + ProtocolRate history on every request. See +/// src/jobs/attribution.ts and src/analytics/attribution.ts (the pure, +/// zero-I/O Brinson-attribution + Cariño-linking core). +/// +/// `sectorBreakdown` is JSON rather than a child table: it is small +/// (bounded by the protocol universe), never queried/filtered/sorted on its +/// own, and always read as a whole alongside the row that owns it — the same +/// precedent as PortfolioOptimization's AllocationSuggestion.weights. +model PortfolioAttribution { + id String @id @default(uuid()) + userId String + windowDays Int + portfolioReturn Float + benchmarkReturn Float + allocationEffect Float + selectionEffect Float + unattributedEffect Float + reconciliationGap Float + reconciled Boolean @default(false) + benchmarkVersion String + sectorBreakdown Json + computedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, windowDays]) + @@index([userId]) + @@map("portfolio_attributions") +} + +/// Precomputed benchmark-relative performance attribution for a PUBLISHED +/// strategy (#320) — what lets the marketplace show `vsBenchmark` next to +/// `apy`/`sharpe` without recomputing per request. Same anonymization boundary +/// as PublishedStrategyMetric: this row is derived from the publisher's own +/// aggregates only and is never joined back to `userId` in a response (see +/// docs/STRATEGY_MARKETPLACE.md). +model StrategyAttribution { + id String @id @default(uuid()) + publishedStrategyId String + windowDays Int + portfolioReturn Float + benchmarkReturn Float + allocationEffect Float + selectionEffect Float + unattributedEffect Float + reconciliationGap Float + reconciled Boolean @default(false) + benchmarkVersion String + sectorBreakdown Json + computedAt DateTime @default(now()) + + publishedStrategy PublishedStrategy @relation(fields: [publishedStrategyId], references: [id], onDelete: Cascade) + + @@unique([publishedStrategyId, windowDays]) + @@index([publishedStrategyId]) + @@map("strategy_attributions") +} + /// Portfolio-optimization suggestion (#322). One row per computed suggestion. /// /// ADVISORY BY CONSTRUCTION. Persisting a suggestion never changes what the diff --git a/scripts/backfill-attribution.ts b/scripts/backfill-attribution.ts new file mode 100644 index 0000000..d64eb44 --- /dev/null +++ b/scripts/backfill-attribution.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env ts-node +/** + * Backfill / on-demand recompute for performance attribution (#320) + * + * Runs computePerformanceAttribution() outside the scheduled interval — for a + * fresh deploy of the attribution migration (no rows exist yet), for a + * benchmark-config change (ATTRIBUTION_BENCHMARK_PROTOCOLS), or as a manual + * repair tool. Idempotent: PortfolioAttribution/StrategyAttribution are + * upserted on (subject, windowDays), so re-running is always safe. + * + * Usage: + * npx ts-node scripts/backfill-attribution.ts [--dry-run] + * + * Environment: + * - Database connection required via DATABASE_URL. + * - ATTRIBUTION_BENCHMARK_PROTOCOLS, if set, narrows the benchmark universe + * exactly as it would for the scheduled job — see src/config/env.ts. + */ + +import db from '../src/db' +import { logger } from '../src/utils/logger' +import { computePerformanceAttribution } from '../src/jobs/attribution' + +const DRY_RUN = process.argv.includes('--dry-run') + +async function main(): Promise { + const [userCount, strategyCount] = await Promise.all([ + db.position.findMany({ distinct: ['userId'], select: { userId: true } }), + db.publishedStrategy.count(), + ]) + + logger.info('[Attribution Backfill] Starting', { + usersWithPositions: userCount.length, + publishedStrategies: strategyCount, + dryRun: DRY_RUN, + }) + + if (DRY_RUN) { + logger.info('[Attribution Backfill] Dry run — no writes') + return + } + + await computePerformanceAttribution() + + logger.info('[Attribution Backfill] Complete') +} + +main() + .catch((err) => { + logger.error('[Attribution Backfill] Failed', { + error: err instanceof Error ? err.message : String(err), + }) + process.exitCode = 1 + }) + .finally(() => db.$disconnect()) diff --git a/src/analytics/attribution.ts b/src/analytics/attribution.ts new file mode 100644 index 0000000..c781b37 --- /dev/null +++ b/src/analytics/attribution.ts @@ -0,0 +1,628 @@ +/** + * Performance attribution — pure computation (#320). + * + * Decomposes a portfolio's (or a published strategy's) return, relative to a + * benchmark, into a Brinson-style allocation effect and selection effect, + * linked across daily periods. Zero I/O, deterministic, unit tested against + * fixture series — src/jobs/attribution.ts is the only thing that reads the DB + * and calls in here, mirroring src/agent/strategyMetrics.ts / + * src/jobs/strategyMetrics.ts. + * + * ─── THE SAME CORRECTNESS TRAP AS strategyMetrics.ts ───────────────────────── + * + * `YieldSnapshot.apy` is a cumulative running average, never a period return + * (docs/STRATEGY_MARKETPLACE.md §2). Attribution is computed from portfolio + * VALUE (`principalAmount + yieldAmount`), bucketed per sector per day — never + * from the raw `apy` column. `ProtocolRate.supplyApy` IS a rate quote and is + * the correct input for the benchmark side, exactly as in + * src/analytics/estimation.ts. + * + * ─── 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 configurable subset; see + * src/jobs/attribution.ts). This module never reads `ProtocolRate` itself — it + * accepts the raw observations as `RawProtocolRatePoint[]` (the same type + * src/agent/backtest.ts already defines), so a real index feed can be dropped + * in later by supplying a differently-sourced series in the same shape. + * + * ─── SECTORS, V1 ────────────────────────────────────────────────────────────── + * + * A "sector" is a protocol name. A protocol-to-sector map (grouping multiple + * protocols into one sector) is a natural v2 extension but is out of scope + * here — see docs/PERFORMANCE_ATTRIBUTION.md. + * + * ─── THE BRINSON MODEL, WITH INTERACTION FOLDED INTO SELECTION ─────────────── + * + * For sector i in period t, with portfolio weight/return (w_p, r_p) and + * benchmark weight/return (w_b, r_b): + * + * allocationEffect_i = (w_p,i - w_b,i) * r_b,i + * selectionEffect_i = w_p,i * (r_p,i - r_b,i) + * + * This is the classic three-term Brinson-Hood-Beebower model + * (allocation + selection + interaction) with the interaction term folded into + * selection — a documented, deliberate choice, not an omission. Folding it in + * keeps the two-term decomposition exact for a single period: + * + * allocationEffect_i + selectionEffect_i = w_p,i * r_p,i - w_b,i * r_b,i + * + * Summed over the full sector universe (portfolio sectors ∪ benchmark + * sectors), the right side telescopes to R_p - R_b — the whole period's + * portfolio-vs-benchmark excess return — with no leftover interaction term to + * separately report or explain to a user. See tests/unit/analytics/attribution.test.ts + * for the identity proof as a fixture test. + * + * ─── WEIGHT GUARDS (never NaN) ──────────────────────────────────────────────── + * + * A sector the portfolio does not hold has w_p,i = 0. Its `portfolioReturn` + * may be `null` (there is nothing to divide by), so `selectionEffect` is + * guarded on `w_p,i > 0` rather than on `portfolioReturn !== null` — otherwise + * `0 * null` would silently become `NaN` in JS instead of the correct `0`. + * + * ─── MULTI-PERIOD LINKING: CARIÑO SMOOTHING ────────────────────────────────── + * + * Period effects are additive per period but returns compound + * multiplicatively, so naively summing daily allocation/selection effects + * across a window does NOT reconcile to the window's actual excess return. + * This module uses the standard Cariño (1999) logarithmic smoothing: each + * period's effects are scaled by `k_t / K`, where `k_t` is derived from that + * period's own portfolio/benchmark returns and `K` from the whole window's + * compounded returns (`carinoFactor` below). This makes the identity + * + * linkedAllocation + linkedSelection + linkedUnattributed = R_P - R_B + * + * hold exactly (mod floating-point epsilon) over the whole window — see + * `linkPeriods` and `RECONCILIATION_TOLERANCE`. + * + * ─── DEGENERATE CASES: NULL/UNATTRIBUTED, NEVER Infinity ────────────────────── + * + * - A sector with no benchmark data for a period cannot be split into + * allocation/selection; its portfolio contribution (w_p,i * r_p,i) flows into + * that period's `unattributed` figure instead of being dropped or guessed at. + * - A period whose compounded return implies a total wipeout (1 + R <= 0) makes + * `carinoFactor` return `null`. Such a period is excluded from the linked sum + * (see `linkPeriods`) and the resulting reconciliation gap is reported + * explicitly rather than fudged. + * - Zero included periods (empty window) return a fully null/zero result — + * never a divide-by-zero. + */ + +import { RawProtocolRatePoint, buildDailyRateSeries } from '../agent/backtest' + +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 + * `reconciled: false`. Cariño linking reconciles exactly in theory; this bound + * exists purely for floating-point accumulation over many periods, not as + * permission to silently absorb real gaps (missing periods/sectors are + * reported through `unattributedEffect`, not this tolerance). + */ +export const RECONCILIATION_TOLERANCE = 1e-6 + +function toUtcDay(d: Date): number { + return Math.floor(d.getTime() / MS_PER_DAY) * MS_PER_DAY +} + +// ── Portfolio value series (per sector, per day) ────────────────────────────── + +/** A raw snapshot row, narrowed to the columns attribution needs, plus the sector it belongs to. */ +export interface PortfolioSectorRow { + snapshotAt: Date + /** Protocol name in v1 — see the module header. */ + sector: string + /** principalAmount + yieldAmount. Never derived from YieldSnapshot.apy. */ + value: number +} + +export interface DailyPortfolioSnapshot { + date: Date + /** Sector name -> value. Absent sector = not held that day, not "unknown". */ + values: Record +} + +/** + * Collapse per-position snapshot rows into one value per (sector, UTC day), + * taking the value from whichever row has the LATEST `snapshotAt` that day + * (an end-of-day mark). Order-independent: rows may arrive in any order. + * + * Deliberately NOT forward-filled, unlike the benchmark series + * (`buildDailyRateSeries`). Snapshots run hourly for every ACTIVE position + * (src/agent/snapshotter.ts), so a day with no row for a still-open position is + * not expected; a day with no row because the position closed correctly reads + * as "not held" (value 0) rather than a stale carried-forward balance. + */ +export function buildDailyPortfolioSectorSeries( + rows: PortfolioSectorRow[], + startDate: Date, + endDate: Date +): DailyPortfolioSnapshot[] { + const startDay = toUtcDay(startDate) + const endDay = toUtcDay(endDate) + + const valueByDay = new Map>() + const latestSeenByDay = new Map>() + + for (const r of rows) { + if (!Number.isFinite(r.value)) continue + const day = toUtcDay(r.snapshotAt) + if (day < startDay || day > endDay) continue + + const seen = latestSeenByDay.get(day) ?? {} + const ts = r.snapshotAt.getTime() + if (seen[r.sector] !== undefined && seen[r.sector] >= ts) continue + seen[r.sector] = ts + latestSeenByDay.set(day, seen) + + const values = valueByDay.get(day) ?? {} + values[r.sector] = r.value + valueByDay.set(day, values) + } + + const dayCount = Math.floor((endDay - startDay) / MS_PER_DAY) + 1 + const series: DailyPortfolioSnapshot[] = [] + for (let i = 0; i < dayCount; i++) { + const day = startDay + i * MS_PER_DAY + series.push({ date: new Date(day), values: valueByDay.get(day) ?? {} }) + } + return series +} + +// ── Single-period Brinson decomposition ──────────────────────────────────────── + +/** One sector's portfolio/benchmark state at the boundary of a period. */ +export interface SectorState { + sector: string + /** w_p,i — 0 when the portfolio does not hold this sector. */ + portfolioWeight: number + /** r_p,i — null when there is no starting value to compute a return from. */ + portfolioReturn: number | null + /** w_b,i — 0 when the benchmark has no data for this sector this period. */ + benchmarkWeight: number + /** r_b,i — null when the benchmark has no data for this sector this period. */ + benchmarkReturn: number | null +} + +export interface SectorEffect { + sector: string + allocationEffect: number + selectionEffect: number +} + +export interface PeriodBrinsonResult { + sectors: SectorEffect[] + /** Sum of w_p,i * r_p,i over sectors with no benchmark comparator this period. */ + unattributed: number + /** R_p,t = sum_i w_p,i * r_p,i (the period's whole-portfolio return, Brinson-native). */ + portfolioReturn: number + /** R_b,t = sum_i w_b,i * r_b,i (the period's whole-benchmark return). */ + benchmarkReturn: number +} + +/** + * Decompose one period's sector states into allocation/selection effects. + * + * `portfolioReturn`/`benchmarkReturn` on the result are DERIVED from the same + * weight*return sums the effects are built from (not an independent + * start/end-value ratio) — this is what makes a period where the portfolio + * started empty (all w_p,i = 0) contribute exactly 0 portfolio return and 0 + * selection effect, without a separate "skip this period" branch: a deposit + * into an empty portfolio is not a return, and the sector-native definition + * makes that fall out for free rather than needing a special case (contrast + * with the explicit <=0-start-value skip in strategyMetrics.periodReturns, + * which this generalizes for the multi-sector case). + */ +export function brinsonPeriod( + sectorStates: SectorState[] +): PeriodBrinsonResult { + const sectors: SectorEffect[] = [] + let unattributed = 0 + let portfolioReturn = 0 + let benchmarkReturn = 0 + + for (const s of sectorStates) { + if (s.portfolioWeight > 0 && s.portfolioReturn !== null) { + portfolioReturn += s.portfolioWeight * s.portfolioReturn + } + if (s.benchmarkWeight > 0 && s.benchmarkReturn !== null) { + benchmarkReturn += s.benchmarkWeight * s.benchmarkReturn + } + + const hasBenchmark = s.benchmarkWeight > 0 && s.benchmarkReturn !== null + if (!hasBenchmark) { + // No comparator this period: cannot split into allocation/selection. + // Flows into `unattributed` rather than being dropped or guessed at. + if (s.portfolioWeight > 0 && s.portfolioReturn !== null) { + unattributed += s.portfolioWeight * s.portfolioReturn + } + continue + } + + const rb = s.benchmarkReturn as number + const allocationEffect = (s.portfolioWeight - s.benchmarkWeight) * rb + // Guarded on portfolioWeight, not on portfolioReturn !== null: a sector the + // portfolio does not hold has weight 0 and no selection story even when + // portfolioReturn happens to be null — `0 * null` must not become NaN. + const selectionEffect = + s.portfolioWeight > 0 && s.portfolioReturn !== null + ? s.portfolioWeight * (s.portfolioReturn - rb) + : 0 + + sectors.push({ sector: s.sector, allocationEffect, selectionEffect }) + } + + return { sectors, unattributed, portfolioReturn, benchmarkReturn } +} + +// ── Multi-period Cariño linking ───────────────────────────────────────────────── + +/** + * Cariño (1999) logarithmic smoothing coefficient for one interval with + * portfolio/benchmark returns (rp, rb): + * + * k = (ln(1+rp) - ln(1+rb)) / (rp - rb), rp != rb + * k = 1 / (1+rp), rp == rb (removable-singularity limit) + * + * Returns null when 1+rp <= 0 or 1+rb <= 0 — a total-wipeout return makes the + * logarithm undefined. Callers must treat null as "cannot link this interval", + * never coerce it to 0 or Infinity. + */ +export function carinoFactor( + portfolioReturn: number, + benchmarkReturn: number +): number | null { + const p1 = 1 + portfolioReturn + const b1 = 1 + benchmarkReturn + if (!(p1 > 0) || !(b1 > 0)) return null + + if (Math.abs(portfolioReturn - benchmarkReturn) < 1e-12) { + return 1 / p1 + } + return (Math.log(p1) - Math.log(b1)) / (portfolioReturn - benchmarkReturn) +} + +/** One period's Brinson decomposition, ready to be linked across the window. */ +export interface LinkedPeriodInput { + portfolioReturn: number + benchmarkReturn: number + sectors: SectorEffect[] + unattributed: number +} + +export interface SectorLinkedEffect { + allocationEffect: number + selectionEffect: number +} + +export interface LinkedAttribution { + /** Compounded portfolio return over every included period. */ + portfolioReturn: number + /** Compounded benchmark return over every included period. */ + benchmarkReturn: number + allocationEffect: number + selectionEffect: number + unattributedEffect: number + /** (portfolioReturn - benchmarkReturn) - (allocation + selection + unattributed). */ + reconciliationGap: number + reconciled: boolean + sectorEffects: Map +} + +/** + * Link a sequence of daily Brinson decompositions into one window-level + * result using Cariño smoothing. Returns null only for an empty input — every + * other degenerate case (a wipeout period, a wipeout total) is reported as an + * explicit `reconciliationGap` with `reconciled: false`, never as NaN/Infinity + * and never silently fudged to force a match. + */ +export function linkPeriods( + periods: LinkedPeriodInput[] +): LinkedAttribution | null { + if (periods.length === 0) return null + + let compoundedP = 1 + let compoundedB = 1 + for (const p of periods) { + compoundedP *= 1 + p.portfolioReturn + compoundedB *= 1 + p.benchmarkReturn + } + const totalP = compoundedP - 1 + const totalB = compoundedB - 1 + + const K = carinoFactor(totalP, totalB) + if (K === null || K === 0) { + // Whole-window wipeout (K undefined) or an exactly-zero scaling factor: + // never divide. Report the raw excess return as unreconciled rather than + // fabricating a linked split for it. + return { + portfolioReturn: totalP, + benchmarkReturn: totalB, + allocationEffect: 0, + selectionEffect: 0, + unattributedEffect: 0, + reconciliationGap: totalP - totalB, + reconciled: totalP === totalB, + sectorEffects: new Map(), + } + } + + let allocationEffect = 0 + let selectionEffect = 0 + let unattributedEffect = 0 + const sectorEffects = new Map() + + for (const p of periods) { + const k = carinoFactor(p.portfolioReturn, p.benchmarkReturn) + // A single-period wipeout is excluded from the linked sum; its + // contribution surfaces honestly as part of the final reconciliationGap. + if (k === null) continue + const scale = k / K + + for (const s of p.sectors) { + allocationEffect += scale * s.allocationEffect + selectionEffect += scale * s.selectionEffect + const entry = sectorEffects.get(s.sector) ?? { + allocationEffect: 0, + selectionEffect: 0, + } + entry.allocationEffect += scale * s.allocationEffect + entry.selectionEffect += scale * s.selectionEffect + sectorEffects.set(s.sector, entry) + } + unattributedEffect += scale * p.unattributed + } + + const reconciliationGap = + totalP - totalB - (allocationEffect + selectionEffect + unattributedEffect) + + return { + portfolioReturn: totalP, + benchmarkReturn: totalB, + allocationEffect, + selectionEffect, + unattributedEffect, + reconciliationGap, + reconciled: Math.abs(reconciliationGap) <= RECONCILIATION_TOLERANCE, + sectorEffects, + } +} + +// ── Top-level: build periods from raw rows and link them ─────────────────────── + +export interface SectorAttribution { + sector: string + /** Time-averaged portfolio weight across the window (0-1). */ + portfolioWeight: number + /** Time-averaged benchmark weight across the window (0-1). */ + benchmarkWeight: number + /** Compounded sector return over periods it was held; null if never held with a computable return. */ + portfolioReturn: number | null + /** Compounded benchmark-sector return over periods it had data; null if it never had data. */ + benchmarkReturn: number | null + /** Linked allocation effect for this sector, in the same units as the window totals. */ + allocationEffect: number + /** Linked selection effect for this sector. */ + selectionEffect: number +} + +export interface AttributionResult { + windowDays: number + /** Number of daily periods in the window (windowDays). */ + periodCount: number + /** Periods that had at least one benchmark sector with data (see below). */ + includedPeriodCount: number + portfolioReturn: number + benchmarkReturn: number + allocationEffect: number + selectionEffect: number + unattributedEffect: number + reconciliationGap: number + reconciled: boolean + sectors: SectorAttribution[] + benchmarkVersion: string +} + +/** A degenerate, all-zero result for a window with nothing to attribute. */ +function emptyResult( + windowDays: number, + benchmarkVersion: string +): AttributionResult { + return { + windowDays, + periodCount: windowDays, + includedPeriodCount: 0, + portfolioReturn: 0, + benchmarkReturn: 0, + allocationEffect: 0, + selectionEffect: 0, + unattributedEffect: 0, + reconciliationGap: 0, + reconciled: true, + sectors: [], + benchmarkVersion, + } +} + +export interface AttributionInput { + /** Raw per-position value rows, any order (job supplies YieldSnapshot joined to Position.protocolName). */ + portfolioRows: PortfolioSectorRow[] + /** + * 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. + */ + benchmarkRates: RawProtocolRatePoint[] + /** 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. */ + now?: Date + /** Label for which benchmark definition/protocol subset produced `benchmarkRates`, echoed onto the result for the report to name. */ + benchmarkVersion: string +} + +/** + * Compute a full window's attribution from raw rows. Builds a daily portfolio + * value series and a daily benchmark rate series, decomposes each day into a + * Brinson period, links them with Cariño smoothing, and rolls up per-sector + * time-averaged weights and compounded returns for the report. + */ +export function computeAttribution(input: AttributionInput): AttributionResult { + const now = input.now ?? new Date() + const endDate = new Date(toUtcDay(now)) + const startDate = new Date(endDate.getTime() - input.windowDays * MS_PER_DAY) + + const portfolioSeries = buildDailyPortfolioSectorSeries( + input.portfolioRows, + startDate, + endDate + ) + const { series: benchmarkSeries } = buildDailyRateSeries( + input.benchmarkRates, + startDate, + endDate + ) + + if (portfolioSeries.length < 2 || benchmarkSeries.length < 2) { + return emptyResult(input.windowDays, input.benchmarkVersion) + } + + const linkedInputs: LinkedPeriodInput[] = [] + + // Per-sector rollups, accumulated alongside the periods. + const weightSum = new Map() + const compoundedPortfolio = new Map< + string, + { product: number; everHeld: boolean } + >() + const compoundedBenchmark = new Map< + string, + { product: number; everSeen: boolean } + >() + + 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 + + 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 + + const benchmarkWeight = 1 / benchmarkSectorCount + const sectorNames = new Set([ + ...Object.keys(prevValues), + ...benchmarkDay.protocols.map((p) => p.name), + ]) + + const sectorStates: SectorState[] = [] + for (const sector of sectorNames) { + const startValue = prevValues[sector] ?? 0 + const endValue = currValues[sector] ?? 0 + const portfolioWeight = + totalPortfolioStart > 0 ? startValue / totalPortfolioStart : 0 + const portfolioReturn = + startValue > 0 ? (endValue - startValue) / startValue : null + + const benchmarkProtocol = benchmarkDay.protocols.find( + (p) => p.name === sector + ) + const hasBenchmark = benchmarkProtocol !== undefined + const benchmarkReturn = hasBenchmark + ? (benchmarkProtocol.apy / 100) * YEAR_FRACTION_PER_DAY + : null + + sectorStates.push({ + sector, + portfolioWeight, + portfolioReturn, + benchmarkWeight: hasBenchmark ? benchmarkWeight : 0, + benchmarkReturn, + }) + + const w = weightSum.get(sector) ?? { p: 0, b: 0 } + w.p += portfolioWeight + w.b += hasBenchmark ? benchmarkWeight : 0 + weightSum.set(sector, w) + + if (portfolioWeight > 0 && portfolioReturn !== null) { + const c = compoundedPortfolio.get(sector) ?? { + product: 1, + everHeld: false, + } + c.product *= 1 + portfolioReturn + c.everHeld = true + compoundedPortfolio.set(sector, c) + } + if (hasBenchmark && benchmarkReturn !== null) { + const c = compoundedBenchmark.get(sector) ?? { + product: 1, + everSeen: false, + } + c.product *= 1 + benchmarkReturn + c.everSeen = true + compoundedBenchmark.set(sector, c) + } + } + + const period = brinsonPeriod(sectorStates) + linkedInputs.push({ + portfolioReturn: period.portfolioReturn, + benchmarkReturn: period.benchmarkReturn, + sectors: period.sectors, + unattributed: period.unattributed, + }) + } + + const linked = linkPeriods(linkedInputs) + if (!linked) return emptyResult(input.windowDays, input.benchmarkVersion) + + const includedPeriodCount = linkedInputs.length + const sectors: SectorAttribution[] = Array.from(weightSum.keys()) + .sort() + .map((sector) => { + const w = weightSum.get(sector) as { p: number; b: number } + const effect = linked.sectorEffects.get(sector) ?? { + allocationEffect: 0, + selectionEffect: 0, + } + const p = compoundedPortfolio.get(sector) + const b = compoundedBenchmark.get(sector) + return { + sector, + portfolioWeight: w.p / includedPeriodCount, + benchmarkWeight: w.b / includedPeriodCount, + portfolioReturn: p?.everHeld ? p.product - 1 : null, + benchmarkReturn: b?.everSeen ? b.product - 1 : null, + allocationEffect: effect.allocationEffect, + selectionEffect: effect.selectionEffect, + } + }) + + return { + windowDays: input.windowDays, + periodCount: input.windowDays, + includedPeriodCount, + portfolioReturn: linked.portfolioReturn, + benchmarkReturn: linked.benchmarkReturn, + allocationEffect: linked.allocationEffect, + selectionEffect: linked.selectionEffect, + unattributedEffect: linked.unattributedEffect, + reconciliationGap: linked.reconciliationGap, + reconciled: linked.reconciled, + sectors, + benchmarkVersion: input.benchmarkVersion, + } +} diff --git a/src/config/env.ts b/src/config/env.ts index d0ea1ba..fe47ed5 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -508,6 +508,27 @@ export const config = { */ riskFreeRate: parseFloat(process.env.STRATEGY_RISK_FREE_RATE || '0'), }, + attribution: { + /** + * Interval between performance-attribution recomputations in ms (default: + * 6 hours, matching strategyMarketplace). Inputs are daily + * YieldSnapshot/ProtocolRate series, so faster buys nothing but load. See + * docs/PERFORMANCE_ATTRIBUTION.md. + */ + intervalMs: parseInt(process.env.ATTRIBUTION_INTERVAL_MS || '21600000'), + /** + * The v1 benchmark is the equal-weighted average of every protocol with + * ProtocolRate history. A comma-separated protocol-name subset narrows + * that universe (e.g. "Aave,Blend" for a stablecoin-only benchmark); + * empty/unset means every protocol. Read at compute time, and the + * resulting `benchmarkVersion` on each report names which subset was + * used, so a later config change never silently reinterprets old rows. + */ + benchmarkProtocols: (process.env.ATTRIBUTION_BENCHMARK_PROTOCOLS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }, allocationSuggestions: { /** * Interval between precomputed allocation-suggestion refreshes in ms diff --git a/src/index.ts b/src/index.ts index 57375bc..25d35f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ import { scheduleRecurringDeposits } from './jobs/recurringDeposits' import { scheduleAlertRules } from './jobs/alertRules' import { scheduleStrategyMetrics } from './jobs/strategyMetrics' import { scheduleAllocationSuggestions } from './jobs/allocationSuggestions' +import { scheduleAttribution } from './jobs/attribution' // Was never imported or started, so ProtocolRiskScore rows were never refreshed // after their first backfill. That matters beyond staleness: risk-ceiling // filtering is fail-closed (applyRiskCeiling treats an unknown score as @@ -117,6 +118,7 @@ let alertRulesHandle: NodeJS.Timeout | null = null let strategyMetricsHandle: NodeJS.Timeout | null = null let allocationSuggestionsHandle: NodeJS.Timeout | null = null let protocolRiskScoringHandle: NodeJS.Timeout | null = null +let attributionHandle: NodeJS.Timeout | null = null function allServicesReady(): boolean { return Object.values(serviceStatus).every((s) => s.ready) @@ -391,6 +393,12 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Allocation suggestions timer cleared') } + if (attributionHandle) { + clearInterval(attributionHandle) + attributionHandle = null + logger.info('[Shutdown] Performance attribution timer cleared') + } + if (!httpServer) { logger.warn('[Shutdown] No HTTP server to close') process.exit(0) @@ -553,6 +561,7 @@ async function main(): Promise { // scored protocols rather than whatever was last left in the table. protocolRiskScoringHandle = scheduleProtocolRiskScoring() allocationSuggestionsHandle = scheduleAllocationSuggestions() + attributionHandle = scheduleAttribution() } // ── Process-level error guards ──────────────────────────────────────────────── diff --git a/src/jobs/attribution.ts b/src/jobs/attribution.ts new file mode 100644 index 0000000..35cd2c1 --- /dev/null +++ b/src/jobs/attribution.ts @@ -0,0 +1,257 @@ +import { Prisma } from '@prisma/client' +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordBackgroundJob } from '../utils/metrics' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { RawProtocolRatePoint } from '../agent/backtest' +import { + AttributionResult, + PortfolioSectorRow, + computeAttribution, +} from '../analytics/attribution' + +/** + * Performance attribution job (#320). + * + * Recomputes benchmark-relative Brinson attribution for every user with + * position history AND for every published strategy, upserting into + * PortfolioAttribution / StrategyAttribution. All the math lives in + * src/analytics/attribution.ts (pure + unit tested); this job is DB glue and + * scheduling only, mirroring src/jobs/strategyMetrics.ts exactly. + * + * Windows are 30 and 90 days only — same retention-honesty rule as + * strategyMetrics: src/agent/snapshotter.ts hard-deletes YieldSnapshot rows + * past 90 days, so a longer window has no data behind it. + * + * Both the portfolio value series and the benchmark rate series are fetched + * ONCE per job run (not once per user/strategy) and then sliced in memory — + * same "one query, N in-memory windows" shape as strategyMetrics.ts, just + * widened to "one query, ALL users" since the underlying tables are not + * scoped to a single subject the way a single strategy's positions are. + */ + +const WINDOWS = [30, 90] as const +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +/** + * Names the benchmark definition + protocol subset that produced a report, so + * a later config change never silently reinterprets an old row. See + * config.attribution.benchmarkProtocols. + */ +function currentBenchmarkVersion(): string { + const subset = config.attribution.benchmarkProtocols + return `equal-weight-v1:${subset.length > 0 ? [...subset].sort().join('+') : 'all'}` +} + +/** Whole-portfolio value rows for every user with position history, keyed by userId. */ +async function loadPortfolioRowsByUser( + cutoff: Date +): Promise> { + const positions = await db.position.findMany({ + select: { id: true, userId: true, protocolName: true }, + }) + const positionById = new Map(positions.map((p) => [p.id, p])) + + const snapshots = await db.yieldSnapshot.findMany({ + where: { snapshotAt: { gte: cutoff } }, + select: { + positionId: true, + snapshotAt: true, + principalAmount: true, + yieldAmount: true, + }, + }) + + const rowsByUser = new Map() + for (const s of snapshots) { + const position = positionById.get(s.positionId) + if (!position) continue // orphaned snapshot (position deleted since); skip rather than guess a sector + + const rows = rowsByUser.get(position.userId) ?? [] + rows.push({ + snapshotAt: s.snapshotAt, + sector: position.protocolName, + value: Number(s.principalAmount) + Number(s.yieldAmount), + }) + rowsByUser.set(position.userId, rows) + } + + return rowsByUser +} + +/** The benchmark's raw rate observations, already filtered to the configured protocol subset. */ +async function loadBenchmarkRates( + cutoff: Date +): Promise { + const subset = config.attribution.benchmarkProtocols + const rates = await db.protocolRate.findMany({ + where: { + fetchedAt: { gte: cutoff }, + ...(subset.length > 0 ? { protocolName: { in: subset } } : {}), + }, + select: { + protocolName: true, + assetSymbol: true, + supplyApy: true, + fetchedAt: true, + }, + }) + + return rates.map((r) => ({ + protocolName: r.protocolName, + assetSymbol: r.assetSymbol, + apy: Number(r.supplyApy), + date: r.fetchedAt, + })) +} + +async function upsertPortfolioAttribution( + userId: string, + windowDays: number, + result: AttributionResult, + computedAt: Date +): Promise { + await db.portfolioAttribution.upsert({ + where: { userId_windowDays: { userId, windowDays } }, + create: { userId, windowDays, ...attributionRowData(result, computedAt) }, + update: attributionRowData(result, computedAt), + }) +} + +async function upsertStrategyAttribution( + publishedStrategyId: string, + windowDays: number, + result: AttributionResult, + computedAt: Date +): Promise { + await db.strategyAttribution.upsert({ + where: { + publishedStrategyId_windowDays: { publishedStrategyId, windowDays }, + }, + create: { + publishedStrategyId, + windowDays, + ...attributionRowData(result, computedAt), + }, + update: attributionRowData(result, computedAt), + }) +} + +function attributionRowData(result: AttributionResult, computedAt: Date) { + return { + portfolioReturn: result.portfolioReturn, + benchmarkReturn: result.benchmarkReturn, + allocationEffect: result.allocationEffect, + selectionEffect: result.selectionEffect, + unattributedEffect: result.unattributedEffect, + reconciliationGap: result.reconciliationGap, + reconciled: result.reconciled, + benchmarkVersion: result.benchmarkVersion, + sectorBreakdown: result.sectors as unknown as Prisma.InputJsonValue, + computedAt, + } +} + +export async function computePerformanceAttribution( + now: Date = new Date() +): Promise { + const correlationId = generateCorrelationId() + return runWithCorrelationIdAsync(correlationId, async () => { + const startTime = Date.now() + const jobName = 'performance_attribution' + + try { + const maxWindow = Math.max(...WINDOWS) + const cutoff = new Date(now.getTime() - maxWindow * MS_PER_DAY) + + const [rowsByUser, benchmarkRates] = await Promise.all([ + loadPortfolioRowsByUser(cutoff), + loadBenchmarkRates(cutoff), + ]) + const benchmarkVersion = currentBenchmarkVersion() + + let portfoliosComputed = 0 + for (const [userId, portfolioRows] of rowsByUser) { + for (const windowDays of WINDOWS) { + const result = computeAttribution({ + portfolioRows, + benchmarkRates, + windowDays, + now, + benchmarkVersion, + }) + await upsertPortfolioAttribution(userId, windowDays, result, now) + } + portfoliosComputed++ + } + + const strategies = await db.publishedStrategy.findMany({ + select: { id: true, userId: true }, + }) + + let strategiesComputed = 0 + for (const strategy of strategies) { + const portfolioRows = rowsByUser.get(strategy.userId) ?? [] + for (const windowDays of WINDOWS) { + const result = computeAttribution({ + portfolioRows, + benchmarkRates, + windowDays, + now, + benchmarkVersion, + }) + await upsertStrategyAttribution(strategy.id, windowDays, result, now) + } + strategiesComputed++ + } + + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 + + logBackgroundJob(jobName, 'success', duration, correlationId, { + portfoliosComputed, + strategiesComputed, + }) + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) + } catch (error) { + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + + logBackgroundJob(jobName, 'failed', duration, correlationId, { + error: errorMessage, + }) + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs) + } + }) +} + +/** + * Schedule the attribution job. Runs once on startup then on the configured + * interval (default 6 h, matching strategyMarketplace). + * + * @returns NodeJS.Timeout handle — pass to clearInterval() on shutdown. + */ +export function scheduleAttribution(): NodeJS.Timeout { + void computePerformanceAttribution() + + const intervalMs = config.attribution.intervalMs + const handle = setInterval(() => { + void computePerformanceAttribution() + }, intervalMs) + + handle.unref?.() + + logger.info( + `[Attribution] Performance attribution scheduled every ${intervalMs / 3600000}h` + ) + return handle +} diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts index 1952096..cba73f4 100644 --- a/src/routes/analytics.ts +++ b/src/routes/analytics.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express' import { z } from 'zod' import db from '../db' import { requireAuth } from '../middleware/authenticate' +import { mapPortfolioAttributionToResponse } from '../utils/api-formatters' const router = Router() @@ -13,6 +14,25 @@ function periodToDays(period: string): number { return period === '7d' ? 7 : period === '30d' ? 30 : 90 } +/** + * `window` accepts 30d/90d only, same retention-honest rule as the strategy + * marketplace (src/validators/strategy-validators.ts): YieldSnapshot rows are + * hard-deleted past 90 days (src/agent/snapshotter.ts), so a longer window + * has no data behind it. + */ +const attributionQuerySchema = z.object({ + window: z + .enum(['30d', '90d'], { + error: + 'window must be "30d" or "90d". Longer windows are unavailable because yield snapshots are retained for 90 days.', + }) + .default('30d'), +}) + +function attributionWindowToDays(window: '30d' | '90d'): number { + return window === '30d' ? 30 : 90 +} + /** * GET /analytics/apy-history * Returns APY snapshots over time for a user's positions (graph-ready). @@ -165,4 +185,48 @@ router.get('/protocol-performance', async (req: Request, res: Response) => { .json({ period: parsed.data.period, protocols: Object.values(byProtocol) }) }) +/** + * GET /analytics/attribution + * + * Benchmark-relative Brinson attribution for the caller's OWN portfolio — + * owner-scoped via req.auth.userId, never a path param (#320). Reads the + * precomputed PortfolioAttribution row rather than recomputing per request; + * see src/jobs/attribution.ts and src/analytics/attribution.ts. + * + * A 200 with `computed: false` (not a 404) is returned when nothing has been + * precomputed yet for this user/window — "no attribution yet" is a normal + * state for a very new account, not a missing resource, mirroring the + * `{ follow: null }` convention in the strategy marketplace. + */ +router.get('/attribution', requireAuth, async (req: Request, res: Response) => { + const userId = req.auth!.userId + const parsed = attributionQuerySchema.safeParse(req.query) + if (!parsed.success) { + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) + } + + const windowDays = attributionWindowToDays(parsed.data.window) + + const row = await db.portfolioAttribution.findUnique({ + where: { userId_windowDays: { userId, windowDays } }, + }) + + if (!row) { + return res.status(200).json({ + userId, + window: parsed.data.window, + computed: false, + }) + } + + return res.status(200).json({ + userId, + window: parsed.data.window, + computed: true, + ...mapPortfolioAttributionToResponse(row), + }) +}) + export default router diff --git a/src/strategy/service.ts b/src/strategy/service.ts index 448beb3..d5a0563 100644 --- a/src/strategy/service.ts +++ b/src/strategy/service.ts @@ -307,13 +307,41 @@ export async function getMarketplace(input: MarketplaceQueryInput): Promise<{ }), ]) + // vsBenchmark (#320) is read alongside, never sorted on: PublishedStrategyMetric + // stays the single ORDER BY/skip/take source (the DoS-prevention rationale + // above), and this is a bounded follow-up lookup over the current page only + // (at most `limit` ids), not a per-request recompute across every publisher. + const strategyIds = rows.map((r) => r.publishedStrategy.id) + const attributions = + strategyIds.length > 0 + ? await db.strategyAttribution.findMany({ + where: { publishedStrategyId: { in: strategyIds }, windowDays }, + select: { + publishedStrategyId: true, + portfolioReturn: true, + benchmarkReturn: true, + }, + }) + : [] + const vsBenchmarkByStrategyId = new Map( + attributions.map((a) => [ + a.publishedStrategyId, + a.portfolioReturn - a.benchmarkReturn, + ]) + ) + + const entries = rows.map((row) => ({ + ...row, + vsBenchmark: vsBenchmarkByStrategyId.get(row.publishedStrategy.id) ?? null, + })) + return { page: input.page, limit: input.limit, total, window: input.window, sortBy: input.sortBy, - entries: rows, + entries, } } diff --git a/src/utils/api-formatters.ts b/src/utils/api-formatters.ts index 3c778ab..6e74cab 100644 --- a/src/utils/api-formatters.ts +++ b/src/utils/api-formatters.ts @@ -43,6 +43,46 @@ export const mapMarketplaceEntryToResponse = (metric: any) => ({ sampleCount: metric.sampleCount, trackRecordDays: metric.trackRecordDays, computedAt: metric.computedAt.toISOString(), + // Benchmark-relative figure (#320): portfolioReturn - benchmarkReturn over the + // window, from StrategyAttribution. Null when attribution has not been + // computed for this strategy/window yet — never fabricated. Merged onto the + // metric row by src/strategy/service.ts's getMarketplace before this mapper + // runs; still only ever a relative figure, never an absolute balance. + vsBenchmark: metric.vsBenchmark ?? null, +}) + +/** + * One sector row of a performance-attribution breakdown (#320). Shared by both + * the owner-scoped portfolio endpoint and the marketplace's vsBenchmark path. + */ +const mapSectorAttribution = (sector: any) => ({ + sector: sector.sector, + portfolioWeight: sector.portfolioWeight, + benchmarkWeight: sector.benchmarkWeight, + portfolioReturn: sector.portfolioReturn, + benchmarkReturn: sector.benchmarkReturn, + allocationEffect: sector.allocationEffect, + selectionEffect: sector.selectionEffect, +}) + +/** + * A precomputed PortfolioAttribution/StrategyAttribution row (#320). Never + * carries userId — the caller already knows whose row this is (owner-scoped + * request, or the publisher's own aggregates for a strategy). + */ +export const mapPortfolioAttributionToResponse = (row: any) => ({ + windowDays: row.windowDays, + portfolioReturn: row.portfolioReturn, + benchmarkReturn: row.benchmarkReturn, + vsBenchmark: row.portfolioReturn - row.benchmarkReturn, + allocationEffect: row.allocationEffect, + selectionEffect: row.selectionEffect, + unattributedEffect: row.unattributedEffect, + reconciliationGap: row.reconciliationGap, + reconciled: row.reconciled, + benchmarkVersion: row.benchmarkVersion, + sectors: (row.sectorBreakdown as any[]).map(mapSectorAttribution), + computedAt: row.computedAt.toISOString(), }) /** The publisher's own view of their listing. */ diff --git a/tests/integration/strategies.integration.test.ts b/tests/integration/strategies.integration.test.ts index 48316e1..cd01c11 100644 --- a/tests/integration/strategies.integration.test.ts +++ b/tests/integration/strategies.integration.test.ts @@ -103,6 +103,9 @@ beforeEach(() => { count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([]), } + mockDb.strategyAttribution = { + findMany: jest.fn().mockResolvedValue([]), + } }) describe('GET /api/v1/strategies/marketplace — anonymity', () => { diff --git a/tests/unit/analytics/attribution.test.ts b/tests/unit/analytics/attribution.test.ts new file mode 100644 index 0000000..5d9db84 --- /dev/null +++ b/tests/unit/analytics/attribution.test.ts @@ -0,0 +1,432 @@ +import { + brinsonPeriod, + buildDailyPortfolioSectorSeries, + carinoFactor, + computeAttribution, + linkPeriods, + RECONCILIATION_TOLERANCE, + SectorState, + LinkedPeriodInput, +} from '../../../src/analytics/attribution' +import { + bucketByInstant, + SnapshotRow, +} from '../../../src/agent/strategyMetrics' +import { RawProtocolRatePoint } from '../../../src/agent/backtest' + +const DAY = 24 * 60 * 60 * 1000 + +describe('brinsonPeriod', () => { + it('splits a single sector into allocation + selection satisfying the exact identity', () => { + const state: SectorState = { + sector: 'Aave', + portfolioWeight: 0.6, + portfolioReturn: 0.02, + benchmarkWeight: 0.4, + benchmarkReturn: 0.01, + } + const result = brinsonPeriod([state]) + const [effect] = result.sectors + + // allocation + selection === w_p*r_p - w_b*r_b (the header's telescoping identity) + const expected = + state.portfolioWeight * (state.portfolioReturn as number) - + state.benchmarkWeight * (state.benchmarkReturn as number) + expect(effect.allocationEffect + effect.selectionEffect).toBeCloseTo( + expected, + 12 + ) + }) + + it('sums allocation + selection across sectors to R_p - R_b', () => { + const states: SectorState[] = [ + { + sector: 'Aave', + portfolioWeight: 0.7, + portfolioReturn: 0.03, + benchmarkWeight: 0.5, + benchmarkReturn: 0.02, + }, + { + sector: 'Compound', + portfolioWeight: 0.3, + portfolioReturn: -0.01, + benchmarkWeight: 0.5, + benchmarkReturn: 0.015, + }, + ] + const result = brinsonPeriod(states) + const totalEffect = result.sectors.reduce( + (s, e) => s + e.allocationEffect + e.selectionEffect, + 0 + ) + expect(totalEffect).toBeCloseTo( + result.portfolioReturn - result.benchmarkReturn, + 12 + ) + }) + + it('a sector the portfolio does not hold contributes pure benchmark allocation effect, not dropped', () => { + const state: SectorState = { + sector: 'Yieldblox', + portfolioWeight: 0, + portfolioReturn: null, + benchmarkWeight: 0.25, + benchmarkReturn: 0.012, + } + const result = brinsonPeriod([state]) + expect(result.sectors).toHaveLength(1) + expect(result.sectors[0].allocationEffect).toBeCloseTo(-0.25 * 0.012, 12) + expect(result.sectors[0].selectionEffect).toBe(0) + }) + + it('never produces NaN when weight is 0 and return is null (0 * null guard)', () => { + const state: SectorState = { + sector: 'Empty', + portfolioWeight: 0, + portfolioReturn: null, + benchmarkWeight: 0.5, + benchmarkReturn: 0.01, + } + const result = brinsonPeriod([state]) + expect(Number.isNaN(result.sectors[0].selectionEffect)).toBe(false) + expect(result.sectors[0].selectionEffect).toBe(0) + }) + + it('a sector with no benchmark data flows into unattributed, not a fabricated effect', () => { + const state: SectorState = { + sector: 'NewProtocol', + portfolioWeight: 0.2, + portfolioReturn: 0.05, + benchmarkWeight: 0, + benchmarkReturn: null, + } + const result = brinsonPeriod([state]) + expect(result.sectors).toHaveLength(0) + expect(result.unattributed).toBeCloseTo(0.2 * 0.05, 12) + }) + + it('an empty-to-funded transition (0 starting weight) contributes 0 portfolio return, not Infinity', () => { + const state: SectorState = { + sector: 'Aave', + portfolioWeight: 0, // portfolio started this period with nothing + portfolioReturn: null, + benchmarkWeight: 1, + benchmarkReturn: 0.01, + } + const result = brinsonPeriod([state]) + expect(result.portfolioReturn).toBe(0) + expect(Number.isFinite(result.portfolioReturn)).toBe(true) + // Still credits the pure benchmark allocation effect for the gap. + expect(result.sectors[0].allocationEffect).toBeCloseTo(-1 * 0.01, 12) + }) +}) + +describe('carinoFactor', () => { + it('matches the closed-form ratio when returns differ', () => { + const k = carinoFactor(0.05, 0.02) + const expected = (Math.log(1.05) - Math.log(1.02)) / (0.05 - 0.02) + expect(k).toBeCloseTo(expected, 12) + }) + + it('uses the removable-singularity limit when returns are equal', () => { + const k = carinoFactor(0.03, 0.03) + expect(k).toBeCloseTo(1 / 1.03, 12) + }) + + it('returns null (never Infinity) on a total wipeout', () => { + expect(carinoFactor(-1, 0.01)).toBeNull() + expect(carinoFactor(0.01, -1)).toBeNull() + expect(carinoFactor(-1.5, 0.01)).toBeNull() + }) +}) + +describe('linkPeriods', () => { + it('reconciles linked totals to the compounded portfolio-vs-benchmark excess return', () => { + const periods: LinkedPeriodInput[] = [ + { + portfolioReturn: 0.001, + benchmarkReturn: 0.0005, + sectors: [ + { sector: 'Aave', allocationEffect: 0.0002, selectionEffect: 0.0003 }, + ], + unattributed: 0, + }, + { + portfolioReturn: -0.0008, + benchmarkReturn: 0.0002, + sectors: [ + { + sector: 'Aave', + allocationEffect: -0.0004, + selectionEffect: -0.0006, + }, + ], + unattributed: 0, + }, + { + portfolioReturn: 0.0015, + benchmarkReturn: 0.001, + sectors: [ + { sector: 'Aave', allocationEffect: 0.0002, selectionEffect: 0.0003 }, + ], + unattributed: 0, + }, + ] + + const linked = linkPeriods(periods) + expect(linked).not.toBeNull() + const l = linked! + + const compoundedP = + periods.reduce((acc, p) => acc * (1 + p.portfolioReturn), 1) - 1 + const compoundedB = + periods.reduce((acc, p) => acc * (1 + p.benchmarkReturn), 1) - 1 + + expect(l.portfolioReturn).toBeCloseTo(compoundedP, 12) + expect(l.benchmarkReturn).toBeCloseTo(compoundedB, 12) + expect( + l.allocationEffect + l.selectionEffect + l.unattributedEffect + ).toBeCloseTo(compoundedP - compoundedB, 9) + expect(Math.abs(l.reconciliationGap)).toBeLessThanOrEqual( + RECONCILIATION_TOLERANCE + ) + expect(l.reconciled).toBe(true) + + // Per-sector linked effects must also sum to the linked totals (single sector here). + const sectorTotal = l.sectorEffects.get('Aave') + expect(sectorTotal!.allocationEffect).toBeCloseTo(l.allocationEffect, 9) + expect(sectorTotal!.selectionEffect).toBeCloseTo(l.selectionEffect, 9) + }) + + it('returns null for an empty period list', () => { + expect(linkPeriods([])).toBeNull() + }) + + it('reports an explicit reconciliationGap rather than fudging when a period is a wipeout', () => { + const periods: LinkedPeriodInput[] = [ + { + portfolioReturn: -1, // total loss this period — carinoFactor(-1, x) is null + benchmarkReturn: 0.01, + sectors: [ + { sector: 'Aave', allocationEffect: 0, selectionEffect: -0.5 }, + ], + unattributed: 0, + }, + { + portfolioReturn: 0.01, + benchmarkReturn: 0.01, + sectors: [{ sector: 'Aave', allocationEffect: 0, selectionEffect: 0 }], + unattributed: 0, + }, + ] + const linked = linkPeriods(periods) + expect(linked).not.toBeNull() + // The wipeout period's effects are excluded from the linked sum by + // construction, so the identity legitimately does not hold — surfaced as + // a real, non-fudged gap rather than NaN or a forced match. + expect(Number.isFinite(linked!.reconciliationGap)).toBe(true) + expect(Number.isNaN(linked!.allocationEffect)).toBe(false) + }) +}) + +describe('buildDailyPortfolioSectorSeries', () => { + const start = new Date('2026-01-01T00:00:00Z') + const end = new Date('2026-01-03T00:00:00Z') + + it('takes the latest snapshot per (sector, day) regardless of row order', () => { + const rows = [ + { + snapshotAt: new Date('2026-01-01T09:00:00Z'), + sector: 'Aave', + value: 100, + }, + { + snapshotAt: new Date('2026-01-01T21:00:00Z'), + sector: 'Aave', + value: 110, + }, + { + snapshotAt: new Date('2026-01-01T15:00:00Z'), + sector: 'Aave', + value: 105, + }, + ] + // Shuffle order deliberately. + const shuffled = [rows[2], rows[0], rows[1]] + const series = buildDailyPortfolioSectorSeries(shuffled, start, end) + expect(series[0].values['Aave']).toBe(110) + }) + + it('a day with no row for a sector reports it as not held (absent), not stale', () => { + const rows = [ + { + snapshotAt: new Date('2026-01-01T09:00:00Z'), + sector: 'Aave', + value: 100, + }, + // No row for 2026-01-02 — position closed. + { + snapshotAt: new Date('2026-01-03T09:00:00Z'), + sector: 'Compound', + value: 50, + }, + ] + const series = buildDailyPortfolioSectorSeries(rows, start, end) + expect(series).toHaveLength(3) + expect(series[0].values['Aave']).toBe(100) + expect(series[1].values['Aave']).toBeUndefined() + expect(series[2].values['Aave']).toBeUndefined() + }) + + it('produces windowDays+1 grid points spanning the window', () => { + const series = buildDailyPortfolioSectorSeries([], start, end) + expect(series).toHaveLength(3) + expect(series[0].date.getTime()).toBe(start.getTime()) + expect(series[2].date.getTime()).toBe(end.getTime()) + }) +}) + +describe('computeAttribution — integration', () => { + const now = new Date('2026-01-11T00:00:00Z') + + function makeBenchmarkRates( + days: number, + protocols: string[] + ): RawProtocolRatePoint[] { + const points: RawProtocolRatePoint[] = [] + for (let d = 0; d <= days; d++) { + const date = new Date(now.getTime() - (days - d) * DAY) + for (const name of protocols) { + points.push({ + protocolName: name, + assetSymbol: 'USDC', + apy: 5, // flat 5% APY for every protocol, every day + date, + }) + } + } + return points + } + + it('reconciles for a simple flat-benchmark, flat-portfolio fixture', () => { + const windowDays = 10 + const portfolioRows = [] + for (let d = 0; d <= windowDays; d++) { + portfolioRows.push({ + snapshotAt: new Date(now.getTime() - (windowDays - d) * DAY), + sector: 'Aave', + value: 1000 * Math.pow(1 + 0.0001, d), // small steady growth + }) + } + + const result = computeAttribution({ + portfolioRows, + benchmarkRates: makeBenchmarkRates(windowDays, ['Aave', 'Compound']), + windowDays, + now, + benchmarkVersion: 'equal-weight-v1:test', + }) + + expect(result.includedPeriodCount).toBe(windowDays) + expect(result.reconciled).toBe(true) + expect(Math.abs(result.reconciliationGap)).toBeLessThanOrEqual( + RECONCILIATION_TOLERANCE + ) + + const aave = result.sectors.find((s) => s.sector === 'Aave') + const compound = result.sectors.find((s) => s.sector === 'Compound') + expect(aave).toBeDefined() + expect(compound).toBeDefined() + // Compound is never held by the portfolio: pure benchmark allocation effect. + expect(compound!.portfolioWeight).toBe(0) + expect(compound!.portfolioReturn).toBeNull() + }) + + it('a sector held by the portfolio but entirely missing from the benchmark is unattributed, not dropped', () => { + const windowDays = 5 + const portfolioRows = [] + for (let d = 0; d <= windowDays; d++) { + portfolioRows.push({ + snapshotAt: new Date(now.getTime() - (windowDays - d) * DAY), + sector: 'ExoticProtocol', + value: 500 + d * 5, + }) + } + + const result = computeAttribution({ + portfolioRows, + benchmarkRates: makeBenchmarkRates(windowDays, ['Aave']), // ExoticProtocol never quoted + windowDays, + now, + benchmarkVersion: 'equal-weight-v1:test', + }) + + // Still reported (weight/return are known), but with zero allocation/selection + // effect — its contribution flows through `unattributedEffect` instead, since + // there is no benchmark comparator to decompose it against. + const exotic = result.sectors.find((s) => s.sector === 'ExoticProtocol') + expect(exotic).toBeDefined() + expect(exotic!.allocationEffect).toBe(0) + expect(exotic!.selectionEffect).toBe(0) + expect(result.unattributedEffect).not.toBe(0) + expect( + Math.abs( + result.allocationEffect + + result.selectionEffect + + result.unattributedEffect - + (result.portfolioReturn - result.benchmarkReturn) + ) + ).toBeLessThanOrEqual(RECONCILIATION_TOLERANCE) + }) + + it('an empty portfolio (no rows) returns a degenerate, finite, unattributed result — never Infinity/NaN', () => { + const result = computeAttribution({ + portfolioRows: [], + benchmarkRates: makeBenchmarkRates(10, ['Aave']), + windowDays: 10, + now, + benchmarkVersion: 'equal-weight-v1:test', + }) + expect(Number.isFinite(result.portfolioReturn)).toBe(true) + expect(Number.isFinite(result.allocationEffect)).toBe(true) + expect(Number.isFinite(result.selectionEffect)).toBe(true) + expect(Number.isNaN(result.reconciliationGap)).toBe(false) + }) + + it('no benchmark data at all returns the empty degenerate result', () => { + const result = computeAttribution({ + portfolioRows: [{ snapshotAt: now, sector: 'Aave', value: 100 }], + benchmarkRates: [], + windowDays: 10, + now, + benchmarkVersion: 'equal-weight-v1:test', + }) + expect(result.includedPeriodCount).toBe(0) + expect(result.reconciled).toBe(true) + expect(result.sectors).toEqual([]) + }) +}) + +describe('anti-divergence: attribution shares the canonical value-series definition', () => { + it('the whole-portfolio value at a given day matches bucketByInstant on the same rows', () => { + const at = new Date('2026-02-01T12:00:00Z') + const rows: SnapshotRow[] = [ + { snapshotAt: at, principalAmount: 100, yieldAmount: 5 }, // position A + { snapshotAt: at, principalAmount: 200, yieldAmount: 10 }, // position B + ] + const sectorRows = [ + { snapshotAt: at, sector: 'Aave', value: 105 }, + { snapshotAt: at, sector: 'Compound', value: 210 }, + ] + + const [wholePortfolioPoint] = bucketByInstant(rows) + const dailySeries = buildDailyPortfolioSectorSeries(sectorRows, at, at) + const totalFromAttribution = Object.values(dailySeries[0].values).reduce( + (s, v) => s + v, + 0 + ) + + expect(totalFromAttribution).toBeCloseTo(wholePortfolioPoint.value, 12) + }) +}) diff --git a/tests/unit/strategy/service.test.ts b/tests/unit/strategy/service.test.ts index b79fbca..922fb8f 100644 --- a/tests/unit/strategy/service.test.ts +++ b/tests/unit/strategy/service.test.ts @@ -81,6 +81,9 @@ beforeEach(() => { count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([]), } + mockDb.strategyAttribution = { + findMany: jest.fn().mockResolvedValue([]), + } mockDispatch.mockResolvedValue(undefined) mockWhatsApp.mockResolvedValue('SM123') }) @@ -466,6 +469,68 @@ describe('getMarketplace', () => { expect(select).not.toHaveProperty('userId') expect(select).not.toHaveProperty('user') }) + + it('merges vsBenchmark from StrategyAttribution without disturbing the SQL sort', async () => { + mockDb.publishedStrategyMetric.findMany.mockResolvedValue([ + { + apy: 12, + sharpe: 1.2, + sampleCount: 40, + trackRecordDays: 45, + windowDays: 30, + computedAt: new Date(), + publishedStrategy: { id: STRATEGY_ID, label: 'Steady yield' }, + }, + ]) + mockDb.strategyAttribution.findMany.mockResolvedValue([ + { + publishedStrategyId: STRATEGY_ID, + portfolioReturn: 0.08, + benchmarkReturn: 0.05, + }, + ]) + + const result = await getMarketplace({ + sortBy: 'sharpe', + window: '30d', + page: 1, + limit: 10, + }) + + expect(mockDb.strategyAttribution.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + publishedStrategyId: { in: [STRATEGY_ID] }, + windowDays: 30, + }, + }) + ) + expect(result.entries[0].vsBenchmark).toBeCloseTo(0.03, 12) + }) + + it('reports vsBenchmark as null when attribution has not been computed for a strategy yet', async () => { + mockDb.publishedStrategyMetric.findMany.mockResolvedValue([ + { + apy: 12, + sharpe: 1.2, + sampleCount: 40, + trackRecordDays: 45, + windowDays: 30, + computedAt: new Date(), + publishedStrategy: { id: STRATEGY_ID, label: 'Steady yield' }, + }, + ]) + // strategyAttribution.findMany resolves [] via the default beforeEach mock. + + const result = await getMarketplace({ + sortBy: 'sharpe', + window: '30d', + page: 1, + limit: 10, + }) + + expect(result.entries[0].vsBenchmark).toBeNull() + }) }) describe('getActiveFollow / loadActiveFollowsForUsers', () => {