Skip to content

Advanced Portfolio Risk Analytics Engine (VaR, CVaR, Sortino & Drawdown) #312

Description

@robertocarlous

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/sharpewithout 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

  1. src/analytics/metrics.ts pure module + exhaustive unit tests (fixture series, degenerate cases, invariance under gap-insertion).
  2. src/analytics/service.ts + series builder (reuse bucketByInstant).
  3. Schema + migration for persisted risk aggregates; src/jobs/portfolioRisk.ts + env config + src/index.ts wiring.
  4. API routes + docs/openapi.yaml.
  5. Re-point marketplace risk figures; add the anti-duplication test.

Acceptance Criteria

  • A pure, zero-I/O risk metrics module with historical + parametric VaR/CVaR, Sortino, downside deviation, max drawdown (+duration), rolling and annualized volatility, and beta-vs-exogenous-benchmark
  • Degenerate cases return null, never 0/Infinity/NaN (explicit tests)
  • Scheduled job persists per-user and per-published-strategy aggregates; leaderboard reads them (SQL ORDER BY) — no per-request recompute
  • Insufficient history flagged and excluded from rankings, never ranked low
  • Windows longer than retained data are rejected or honestly relabeled — never silently truncated
  • GET /api/v1/analytics/risk and GET /api/v1/analytics/risk/timeseries with ownership enforcement
  • Exactly one Sharpe/volatility definition in the codebase — a test fails on a second implementation
  • docs/openapi.yaml updated; npm run validate:spec green; unit + integration tests green

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions