Problem Statement
The platform reports returns (APY history, user yield, strategy marketplace Sharpe) but has no risk analytics. A user cannot answer the two questions that determine whether they should move money at all: "How much can I lose in a bad month?" and "Is this yield actually worth the volatility?". docs/STRATEGY_MARKETPLACE.md explicitly flags this gap: issue #225 (portfolio analytics: Sharpe / Sortino / volatility) "has not landed — there is no analytics service in src/". This issue delivers that missing layer: a single, authoritative risk/performance analytics engine that every consumer (the /api/analytics routes, the strategy marketplace, and future product surfaces) reads from — not a third ad-hoc definition of risk-adjusted return bolted onto a route.
Current State
src/agent/strategyMetrics.ts is currently the only definition of risk-adjusted return in the codebase (Sharpe, sample stdev, annualization via median spacing). docs/STRATEGY_MARKETPLACE.md §2 documents the trap: YieldSnapshot.apy is a cumulative, smoothed running average — not a period return — so any volatility computed from the raw APY column would badly understate risk. The correct input series is portfolio value (principalAmount + yieldAmount, summed across positions sharing an exact snapshotAt).
src/routes/analytics.ts computes trivial metrics inline (simple average APY, cumulative/period yield) with no precomputation, no risk statistics, and no reuse of strategyMetrics.
YieldSnapshot rows are hard-deleted past 90 days by src/agent/snapshotter.ts, and src/agent/strategyMetrics.ts only ever keeps 30d/90d windows. Any new engine must be honest about this retention bound (a "1y" figure computed from 90 days of data is a lie).
ProtocolRate history (with rawResponse from the collection source) is retained without a documented bound, and src/jobs/protocolRiskScoring.ts already computes per-protocol volatility/trend factors — a precedent for scheduled, persisted aggregates.
Proposed Solution
1. Core risk analytics module (pure, zero-I/O)
A new src/analytics/ domain, mirroring the src/tax/ layering (fifo.ts = pure math, service.ts = DB I/O):
-
src/analytics/metrics.ts — pure functions, deterministic, unit-tested against fixture series:
- Period returns from a portfolio-value series, with the same "skip intervals whose starting value is
<= 0" guard as strategyMetrics (a portfolio funded from empty is a deposit, not a return).
- Historical VaR and CVaR (95%/99%) over 7d/30d/90d windows, both plain-historical and parametric (mean/σ with a normal assumption), documented as distinct estimators.
- Sortino ratio (downside deviation with a configurable MAR), downside deviation, max drawdown, max drawdown duration, rolling volatility (annualized), annualized volatility.
- Beta vs. a benchmark — requires a benchmark series input; the module must accept an exogenous series so a benchmark index can be plugged in later without changing the math.
- A shared annualization convention and a shared
inferPeriodsPerYear (move/import from strategyMetrics so there is exactly one definition).
- Explicit degenerate-case contract (mirroring the
null-not-0 rule for Sharpe): metrics that are not computable (empty series, zero variance, insufficient samples) return null, never a fabricated 0, and never Infinity/NaN that would sort to the top of any ranking.
-
src/analytics/service.ts — the only file allowed to read the DB and call into metrics.ts. Builds the portfolio-value series with the same bucketByInstant aggregation as src/jobs/alertRules.ts, enforces the 90-day retention bound (rejects windows it cannot honestly serve), and exposes getPortfolioRisk(userId, window) plus getStrategyRiskMetrics(publishedStrategyId, window).
2. Scheduled persistence (prevent DoS + enable SQL ordering)
A new src/jobs/portfolioRisk.ts (registered in src/index.ts alongside the other schedule* jobs, with config in src/config/env.ts, wired into gracefulShutdown) that:
- Precomputes per-user and per-published-strategy risk metrics on a schedule (e.g. 6h, configurable) and persists them into new tables so the leaderboard and dashboards can
ORDER BY in SQL without recomputing every request — the same precedent as ProtocolRiskScore + PublishedStrategyMetric.
- Writes insufficient-history flags rather than omitting rows, so a thin track record is visible in the data but excluded from rankings (mirrors
insufficientHistory in src/agent/riskScoring.ts).
- Emits an operational alert (via
alertingService) if a compute run fails after N attempts, and self-heals by re-running.
3. API surface (src/routes/analytics.ts + docs/openapi.yaml)
GET /api/v1/analytics/risk — authenticated: 7d/30d/90d VaR, CVaR, Sortino, max drawdown (+ duration), volatility, sample count, insufficient-history flag, and the exact window of data actually used.
GET /api/v1/analytics/risk/timeseries — graph-ready rolling-volatility and drawdown series.
- Extend
GET /api/v1/strategies/marketplace and the strategy detail view to return the persisted risk figures alongside apy/sharpe — without adding a third definition of Sharpe (re-point the marketplace read at this engine; keep strategyMetrics' computation as the canonical implementation if you prefer, but one of them must become the consumer of the other — do not duplicate).
4. The #225 re-pointing requirement
docs/STRATEGY_MARKETPLACE.md §2: "When #225 lands, re-point this module at that service. Do not add a third definition." Whichever side becomes canonical, the other must import it. Add a test that fails if a second, divergent Sharpe/volatility implementation is introduced.
Edge Cases & Failure Modes
- Irregularly spaced series (snapshot gaps, missed cron runs): the annualization must be robust to gaps (median spacing, as
strategyMetrics does) and the window must report the real data span, not the wall-clock span.
- Portfolio funded from empty: intervals with starting value
<= 0 must be excluded from return computation, and an entirely-unfunded history must yield null risk metrics, not a fabricated 0 drawdown.
- Degenerate/flat series: zero variance →
null Sortino/VaR-Z, never Infinity.
- Retention boundary: a request for a window longer than available data must return the honest available span + a flag, never silently serve a truncated window under the requested label. The 90-day hard-delete in
snapshotter.ts is load-bearing here.
- One position closes / user deletes an account mid-window: value series must not double-count or leak across users; the aggregation must be user-scoped end to end.
- Concurrency of precompute vs. new snapshots: persisted aggregates are point-in-time; document staleness and surface
computedAt.
Security & Privacy Considerations
- Risk figures for published strategies must be derived from the publisher's own aggregates only — never absolute currency values, never
userId. Follow the marketplaceSelect / allowlist-mapper pattern from src/strategy/service.ts.
- User-scoped endpoints must enforce
enforceUserAccess / ownership exactly as src/routes/alerts.ts and src/routes/sub-accounts.ts do (including parent/child sub-account access via req.auth.actingAsUserId semantics).
- Precomputed tables are a potential enumeration vector: ensure listing endpoints paginate, rate-limit appropriately, and never accept a
userId path param from a caller to read another user's risk data.
Out of Scope
- Benchmark-index ingestion (the module must accept an exogenous series, but sourcing live index data is deferred).
- ML-based risk forecasting; this issue is statistical estimators only.
- Changes to the 90-day snapshot retention policy (flag it for a follow-up if it blocks product ambitions).
Suggested Implementation Plan
src/analytics/metrics.ts pure module + exhaustive unit tests (fixture series, degenerate cases, invariance under gap-insertion).
src/analytics/service.ts + series builder (reuse bucketByInstant).
- Schema + migration for persisted risk aggregates;
src/jobs/portfolioRisk.ts + env config + src/index.ts wiring.
- API routes +
docs/openapi.yaml.
- Re-point marketplace risk figures; add the anti-duplication test.
Acceptance Criteria
Problem Statement
The platform reports returns (APY history, user yield, strategy marketplace Sharpe) but has no risk analytics. A user cannot answer the two questions that determine whether they should move money at all: "How much can I lose in a bad month?" and "Is this yield actually worth the volatility?".
docs/STRATEGY_MARKETPLACE.mdexplicitly flags this gap: issue #225 (portfolio analytics: Sharpe / Sortino / volatility) "has not landed — there is no analytics service insrc/". This issue delivers that missing layer: a single, authoritative risk/performance analytics engine that every consumer (the/api/analyticsroutes, the strategy marketplace, and future product surfaces) reads from — not a third ad-hoc definition of risk-adjusted return bolted onto a route.Current State
src/agent/strategyMetrics.tsis currently the only definition of risk-adjusted return in the codebase (Sharpe, sample stdev, annualization via median spacing).docs/STRATEGY_MARKETPLACE.md§2 documents the trap:YieldSnapshot.apyis a cumulative, smoothed running average — not a period return — so any volatility computed from the raw APY column would badly understate risk. The correct input series is portfolio value (principalAmount + yieldAmount, summed across positions sharing an exactsnapshotAt).src/routes/analytics.tscomputes trivial metrics inline (simple average APY, cumulative/period yield) with no precomputation, no risk statistics, and no reuse ofstrategyMetrics.YieldSnapshotrows are hard-deleted past 90 days bysrc/agent/snapshotter.ts, andsrc/agent/strategyMetrics.tsonly ever keeps 30d/90d windows. Any new engine must be honest about this retention bound (a "1y" figure computed from 90 days of data is a lie).ProtocolRatehistory (withrawResponsefrom the collection source) is retained without a documented bound, andsrc/jobs/protocolRiskScoring.tsalready computes per-protocol volatility/trend factors — a precedent for scheduled, persisted aggregates.Proposed Solution
1. Core risk analytics module (pure, zero-I/O)
A new
src/analytics/domain, mirroring thesrc/tax/layering (fifo.ts= pure math,service.ts= DB I/O):src/analytics/metrics.ts— pure functions, deterministic, unit-tested against fixture series:<= 0" guard asstrategyMetrics(a portfolio funded from empty is a deposit, not a return).inferPeriodsPerYear(move/import fromstrategyMetricsso there is exactly one definition).null-not-0rule for Sharpe): metrics that are not computable (empty series, zero variance, insufficient samples) returnnull, never a fabricated0, and neverInfinity/NaNthat would sort to the top of any ranking.src/analytics/service.ts— the only file allowed to read the DB and call intometrics.ts. Builds the portfolio-value series with the samebucketByInstantaggregation assrc/jobs/alertRules.ts, enforces the 90-day retention bound (rejects windows it cannot honestly serve), and exposesgetPortfolioRisk(userId, window)plusgetStrategyRiskMetrics(publishedStrategyId, window).2. Scheduled persistence (prevent DoS + enable SQL ordering)
A new
src/jobs/portfolioRisk.ts(registered insrc/index.tsalongside the otherschedule*jobs, with config insrc/config/env.ts, wired intogracefulShutdown) that:ORDER BYin SQL without recomputing every request — the same precedent asProtocolRiskScore+PublishedStrategyMetric.insufficientHistoryinsrc/agent/riskScoring.ts).alertingService) if a compute run fails after N attempts, and self-heals by re-running.3. API surface (
src/routes/analytics.ts+docs/openapi.yaml)GET /api/v1/analytics/risk— authenticated: 7d/30d/90d VaR, CVaR, Sortino, max drawdown (+ duration), volatility, sample count, insufficient-history flag, and the exact window of data actually used.GET /api/v1/analytics/risk/timeseries— graph-ready rolling-volatility and drawdown series.GET /api/v1/strategies/marketplaceand the strategy detail view to return the persisted risk figures alongsideapy/sharpe— without adding a third definition of Sharpe (re-point the marketplace read at this engine; keepstrategyMetrics' computation as the canonical implementation if you prefer, but one of them must become the consumer of the other — do not duplicate).4. The #225 re-pointing requirement
docs/STRATEGY_MARKETPLACE.md§2: "When #225 lands, re-point this module at that service. Do not add a third definition." Whichever side becomes canonical, the other must import it. Add a test that fails if a second, divergent Sharpe/volatility implementation is introduced.Edge Cases & Failure Modes
strategyMetricsdoes) and the window must report the real data span, not the wall-clock span.<= 0must be excluded from return computation, and an entirely-unfunded history must yieldnullrisk metrics, not a fabricated0drawdown.nullSortino/VaR-Z, neverInfinity.snapshotter.tsis load-bearing here.computedAt.Security & Privacy Considerations
userId. Follow themarketplaceSelect/ allowlist-mapper pattern fromsrc/strategy/service.ts.enforceUserAccess/ ownership exactly assrc/routes/alerts.tsandsrc/routes/sub-accounts.tsdo (including parent/child sub-account access viareq.auth.actingAsUserIdsemantics).userIdpath param from a caller to read another user's risk data.Out of Scope
Suggested Implementation Plan
src/analytics/metrics.tspure module + exhaustive unit tests (fixture series, degenerate cases, invariance under gap-insertion).src/analytics/service.ts+ series builder (reusebucketByInstant).src/jobs/portfolioRisk.ts+ env config +src/index.tswiring.docs/openapi.yaml.Acceptance Criteria
null, never0/Infinity/NaN(explicit tests)ORDER BY) — no per-request recomputeGET /api/v1/analytics/riskandGET /api/v1/analytics/risk/timeserieswith ownership enforcementdocs/openapi.yamlupdated;npm run validate:specgreen; unit + integration tests green